Implement a high-performant and scalable networking layer
These days it’s hard to find an app that doesn’t perform any API calls. So might be yours. Luckily, Flutter provides packages out of the box to make this process a bit simpler. Examples of those could be easily found on pub.dev: HTTP and DIO.
Essentially, both packages allow you to assemble a network request and then perform it. Leaving to you error handling, JSON parsing, and all other aspects of mobile application networking.
For example, your API call looks like this:
final dio = Dio() | |
..options.baseUrl = 'https://www.domain.com/api'; | |
..options.connectTimeout = 5000; // 5 seconds | |
..options.receiveTimeout = 3000; // 3 seconds | |
try { | |
final response = await dio.post('/authenticate', data: {'login': 'user_name', 'password': 'password'}); | |
// Check response then response.data and convert response to your model | |
} on Dio.DioError catch (error) { | |
// Handle Dio exceptions here | |
} catch (error) { | |
// Handle generic exceptions here | |
} |
This code is totally fine for a small pet project. But you start noticing inconveniences when the project starts growing at the scale stage.
Specifically:
❌ Duplicate code
❌ Blocking main thread and dropping FPS
❌ Error-prone codebase
❌ More and more time on adding new services and API calls
❌ Unreliable error handling
Smells bad🤮. Right?
Here we’re going to tackle all these issues and implement high-performant and scalable networking layer. Let’s start from the very top. Ideally, our API call should look very simple:
//Instantiate a service and keep it in your DI container: | |
final service = NetworkService(baseUrl: 'https://www.domain.com/api'); | |
// Prepare a request: | |
final request = NetworkRequest( | |
type: NetworkRequestType.POST, | |
path: '/authenticate', | |
data: NetworkRequestBody.json({ | |
'login': 'user_name', | |
'password': 'password' | |
}), | |
); | |
// Execute a request and convert response to your model: | |
final response = await service.execute( | |
request, | |
AccessTokenResponse.fromJson, // <- Function to convert API response to your model | |
); | |
// Handle possible outcomes: | |
response.maybeWhen( | |
ok: (authResponse) { | |
// Save access token or proceed with other parts of your app | |
}, | |
badRequest: (info) { | |
// Handle specific error | |
}, | |
orElse: () { | |
// Handle generic error | |
} | |
); |
Quite nice?
Now let’s start crafting our Networking Layer step by step with a help of one amazing package: Freezed (yet another code generator for unions/pattern-matching and copy)!
Step 0: Design our data model. Let it be something easy to grasp
class AccessTokenResponse { | |
String? accessToken; | |
AccessTokenResponse.fromJson(Map<String, dynamic> json) { | |
accessToken = json['access_token']; | |
} | |
} |
Step 1: Create a flexible request body
Request body encapsulated data to send along with your API call and could contain JSON object, binary data, text, etc.
import 'package:freezed_annotation/freezed_annotation.dart'; | |
part 'NetworkRequestBody.freezed.dart'; | |
@freezed | |
class NetworkRequestBody with _$NetworkRequestBody { | |
const factory NetworkRequestBody.empty() = Empty; | |
const factory NetworkRequestBody.json(Map<String, dynamic> data) = Json; | |
const factory NetworkRequestBody.text(String data) = Text; | |
} |
Step 2: Create a request
Our request will contain common parameters you might need to serve an API call: type (Get, Post, etc), API path, data to pass as a body, optionally query parameters and headers to override globally specified headers if needed:
enum NetworkRequestType { GET, POST, PUT, PATCH, DELETE } | |
class NetworkRequest { | |
const NetworkRequest({ | |
required this.type, | |
required this.path, | |
required this.data, | |
this.queryParams, | |
this.headers, | |
}); | |
final NetworkRequestType type; | |
final String path; | |
final NetworkRequestBody data; | |
final Map<String, dynamic>? queryParams; | |
final Map<String, String>? headers; | |
} |
Step 3. Create a response
Our response object will contain only raw data received from your API:
import 'package:freezed_annotation/freezed_annotation.dart'; | |
part 'NetworkResponse.freezed.dart'; | |
@freezed | |
class NetworkResponse<Model> with _$NetworkResponse { | |
const factory NetworkResponse.ok(Model data) = Ok; | |
const factory NetworkResponse.invalidParameters(String message) = InvalidParameters; | |
const factory NetworkResponse.noAuth(String message) = NoAuth; //401 | |
const factory NetworkResponse.noAccess(String message) = NoAccess; //403 | |
const factory NetworkResponse.badRequest(String message) = BadRequest; //400 | |
const factory NetworkResponse.notFound(String message) = NotFound; //404 | |
const factory NetworkResponse.conflict(String message) = Conflict; //409 | |
const factory NetworkResponse.noData(String message) = NoData; //500 | |
} |
Now we are ready to implement the most intriguing part: network service/layer itself!
Step 4. Let’s prototype our service with what we already have
Our service needs a base URL and optionally DIO instance and HTTP headers. It will create a default DIO instance if not provided:
class NetworkService { | |
NetworkService({ | |
required this.baseUrl, // Base service url | |
dioClient, // Prepared Dio instance could be injected | |
httpHeaders, // Global headers could be provided as well | |
}) : this._dio = dioClient, | |
this._headers = httpHeaders ?? {}; | |
Dio? _dio; | |
final String baseUrl; | |
Map<String, String> _headers; | |
Future<Dio> _getDefaultDioClient() async { | |
// Global http header | |
_headers['content-type'] = 'application/json; charset=utf-8'; | |
final dio = Dio.Dio() | |
..options.baseUrl = baseUrl | |
..options.headers = _headers | |
..options.connectTimeout = 5000 // 5 seconds | |
..options.receiveTimeout = 3000; // 3 seconds | |
return dio; | |
} | |
// Generic type and parser are used to properly deserialise JSON | |
Future<NetworkResponse<Model>> execute<Model>( | |
NetworkRequest request, | |
Model Function(Map<String, dynamic>) parser, { | |
ProgressCallback? onSendProgress = null, | |
ProgressCallback? onReceiveProgress = null, | |
}) async { | |
if (_dio == null) { | |
_dio = await _getDefaultDioClient(); | |
} | |
// 1. Prepare the request | |
// 2. Execute it | |
// 3. Return parsed result or error | |
} | |
} |
You can add other body types and conversions here so I’m leaving it to you.
Everything is ready to execute our request and handle exceptions:
try { | |
final response = await _dio!.request( | |
request.path, | |
data: body, | |
queryParameters: request.queryParams, | |
options: Dio.Options( | |
method: request.type.name, | |
headers: {..._headers, ...(request.headers ?? {})}, // Combine all headers | |
), | |
onSendProgress: onSendProgress, | |
onReceiveProgress: onReceiveProgress, | |
); | |
return NetworkResponse.ok(parser(response.data)); | |
} on DioError catch (error) { | |
final errorText = error.toString(); | |
if (error.requestOptions.cancelToken!.isCancelled) { | |
return NetworkResponse.noData(errorText); | |
} | |
switch (error.response?.statusCode) { | |
case 400: | |
return NetworkResponse.badRequest(errorText); | |
case 401: | |
return NetworkResponse.noAuth(errorText); | |
case 403: | |
return NetworkResponse.noAccess(errorText); | |
case 404: | |
return NetworkResponse.notFound(errorText); | |
case 409: | |
return NetworkResponse.conflict(errorText); | |
default: | |
return NetworkResponse.noData(errorText); | |
} | |
} catch (error) { | |
return NetworkResponse.noData(error.toString()); | |
} |
We can also add a method(s) to simplify authenticated API calls:
void addBasicAuth(String accessToken) { | |
_headers['Authorization'] = 'Bearer $accessToken'; | |
} |
Let’s summarise what we’ve got here:
- API call is prepared with all headers combined and parameters incapsulated
- API call is executed by DIO package
- Data returned from DIO is converted to your Model type provided
- Exceptions are handled and wrapped in a corresponding API response
But all this stuff still happens on main thread. And this is something we are going to fix now by utilizing a powerful yet simple Isolates mechanism in Dart.
Step 5. Make Networking Layer to be performant!
The way Dart work is organized to pass data between Isolates we need to copy all parameters and then call a globally defined function. We will encapsulate all data in a private PreparedNetworkRequest
class:
class _PreparedNetworkRequest<Model> { | |
const _PreparedNetworkRequest( | |
this.request, | |
this.parser, | |
this.dio, | |
this.headers, | |
this.onSendProgress, | |
this.onReceiveProgress, | |
); | |
final NetworkRequest request; | |
final Model Function(Map<String, dynamic>) parser; | |
final Dio dio; | |
final Map<String, dynamic> headers; | |
final ProgressCallback? onSendProgress; | |
final ProgressCallback? onReceiveProgress; | |
} |
And now all we need to do is to move request execution call along with exception handling into Isolate function:
Future<NetworkResponse<Model>> executeRequest<Model>( | |
_PreparedNetworkRequest request, | |
) async { | |
try { | |
dynamic body = request.request.data.whenOrNull( | |
json: (data) => data, | |
text: (data) => data, | |
); | |
final response = await request.dio.request( | |
request.request.path, | |
data: body, | |
queryParameters: request.request.queryParams, | |
options: Dio.Options( | |
method: request.request.type.name, | |
headers: request.headers, | |
), | |
onSendProgress: request.onSendProgress, | |
onReceiveProgress: request.onReceiveProgress, | |
); | |
return NetworkResponse.ok(request.parser(response.data)); | |
} on Dio.DioError catch (error) { | |
final errorText = error.toString(); | |
if (error.requestOptions.cancelToken!.isCancelled) { | |
return NetworkResponse.noData(errorText); | |
} | |
switch (error.response?.statusCode) { | |
case 400: | |
return NetworkResponse.badRequest(errorText); | |
case 401: | |
return NetworkResponse.noAuth(errorText); | |
case 403: | |
return NetworkResponse.noAccess(errorText); | |
case 404: | |
return NetworkResponse.notFound(errorText); | |
case 409: | |
return NetworkResponse.conflict(errorText); | |
default: | |
return NetworkResponse.noData(errorText); | |
} | |
} catch (error) { | |
return NetworkResponse.noData(error.toString()); | |
} | |
} |
And call it in our Network Service:
final req = _PreparedNetworkRequest<Model>( | |
request, | |
parser, | |
_dio!, | |
{..._headers, ...(request.headers ?? {})}, | |
onSendProgress, | |
onReceiveProgress, | |
); | |
final result = await compute( | |
executeRequest<Model>, | |
req, | |
); |
Huh! We’re done coding☺️.
Finally, our NetworkService
looks like this:
class NetworkService { | |
NetworkService({ | |
required this.baseUrl, | |
dioClient, | |
httpHeaders, | |
}) : this._dio = dioClient, | |
this._headers = httpHeaders ?? {}; | |
Dio? _dio; | |
final String baseUrl; | |
Map<String, String> _headers; | |
Future<Dio> _getDefaultDioClient() async { | |
_headers['content-type'] = 'application/json; charset=utf-8'; | |
final dio = Dio() | |
..options.baseUrl = baseUrl | |
..options.headers = _headers | |
..options.connectTimeout = 5000 // 5 seconds | |
..options.receiveTimeout = 3000; // 3 seconds | |
} | |
return dio; | |
} | |
void addBasicAuth(String accessToken) { | |
_headers['Authorization'] = 'Bearer $accessToken'; | |
} | |
Future<NetworkResponse<Model>> execute<Model>( | |
NetworkRequest request, | |
Model Function(Map<String, dynamic>) parser, { | |
ProgressCallback? onSendProgress = null, | |
ProgressCallback? onReceiveProgress = null, | |
}) async { | |
if (_dio == null) { | |
_dio = await _getDefaultDioClient(); | |
} | |
final req = _PreparedNetworkRequest<Model>( | |
request, | |
parser, | |
_dio!, | |
{..._headers, ...(request.headers ?? {})}, | |
onSendProgress, | |
onReceiveProgress, | |
); | |
final result = await compute( | |
executeRequest<Model>, | |
req, | |
); | |
return result; | |
} | |
} |
Recap of what we’ve achieved today
✅ No duplicate code
✅ Not blocking main thread and dropping FPS
✅ Error-proof codebase
✅ Minimised time on adding new services and API calls
✅ Structured and strongly-typed error handling
Resources
Concurrency in Dart
Freezed Dart package
Dio Dart package
RealHTTP Modern Networking Layers in iOS Using Async/Await
Full source code available here
To get hand-picked the latest tech stories subscribe on my Telegram Channel where I post daily.
Happy coding!😉
Top comments (0)