Skip to content

Latest commit

 

History

History
197 lines (135 loc) · 17.3 KB

08.md

File metadata and controls

197 lines (135 loc) · 17.3 KB

Chapter 8: HTTP communication

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.

Code Generators: Serialization and De-serialization

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() and jsonDecode() functions (found in the convert library included with the SDK) to convert between Dart types and their JSON String 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 XML Strings 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 with String 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().

DartPad

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() and fromJson() function only delegate to the corresponding generated private functions, currently there is no way to avoid these boilerplate function declarations. While fromJson() could be moved to a generated extensions class in theory, the toJson() function must be declared inside the class for jsonEncode() 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.

Network communication: the http package

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 is Map<String, dynamic>, the type of the value objects must be either String or Iterable<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 in ow_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 the physics: const AlwaysScrollableScrollPhysics() parameter to our ListView to allow the over scrolling needed for the indicator to appear. The onRefresh() callback is called when refresh is needed. The refresh indicator is shown until the Future object returned from the callback is completed. If we want to show the indicator manually, we would have to use a GlobalKey on the widget and call the show() method on the corresponding State object.
  • The FutureBuilder widget subscribes to the Future object and calls the builder callback whenever the state of the Future changes. Remember that the build() function may be called every frame, so we must not create the Future object in this method. Instead, we use a StatefulWidget to store the current Future object. This is similar to how we can use the FutureProvider.value() constructor from the provider package. However, if we were to use a FutureProvider, we would have to create the Future object in the create() callback (remember the difference between the default and the value constructor from the provider 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 new Isolate and parse the JSON there. We must pass a top-level or static function to the compute() function, which will be the entry point of the new Isolate, and must take one argument, which comes from the other parameter of the compute() function. Note that due to the web platform's limitation, this code will run on the main isolate instead when targeting the web.

Network communication: dio

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 a RequestOptions object.
  • onResponse(): After the request finished successfully. Receives a Response object.
  • onError(): After the request finished unsuccessfully. Receives a DioError 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 the onRequest() or onResponse()/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 and chopper, 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.

Conclusion

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.