Encapsulating Network Requests in Flutter with Dio and Riverpod
This article demonstrates how to encapsulate Flutter network requests using Dio, create a reusable ApiClient with singleton pattern, integrate Riverpod for automatic UI updates, handle errors with custom exceptions, and organize code with repositories and providers for clean architecture.
1. Introduction
The author revisits previous articles on JSON parsing and network requests, noting their shortcomings, and proposes a more comfortable way to encapsulate network requests using Riverpod for state management.
2. Encapsulated Effect Demo
Shows UI screenshots of API definition, UI call site, and runtime output, along with a download link for the demo project.
3. Common Encapsulation Techniques
3.1. Singleton & Multiton
Explains the singleton design pattern and provides a Dart implementation with a private constructor, static instance, and accessor method.
class Singleton {
// ① private constructor
Singleton._internal();
// ② static private instance
static Singleton? _instance = Singleton._internal();
// ③ static getter for the instance
static Singleton get instance => _instance ??= Singleton._internal();
int _counter = 0;
void incrementCounter() => _counter++;
int get counter => _counter;
}
void main() {
var s1 = Singleton.instance;
var s2 = Singleton.instance;
print(identical(s1, s2)); // true
s1.incrementCounter();
print(s2.counter); // 1
}Describes multiton implementation using a static map keyed by a string.
class Multiple {
Multiple._internal();
static final Map
_instances = {};
static Multiple getInstance(String key) {
_instances.putIfAbsent(key, () => Multiple._internal());
return _instances[key]!;
}
}
void main() {
var m1 = Multiple.getInstance("a");
var m2 = Multiple.getInstance("b");
var m3 = Multiple.getInstance("a");
print(identical(m1, m2)); // false
print(identical(m1, m3)); // true
}3.2. Compile‑time Code Generation
Introduces Dart's source_gen and build_runner for generating code at compile time, with an example of a @ToString annotation that generates a custom toString method.
class ToString {}
class ToStringGenerator extends GeneratorForAnnotation
{
@override
Future
generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) async {
// generate toString extension
}
}3.3. Generics
Discusses Dart's generic classes, methods, and bounds, contrasting them with Java's type erasure. Provides examples of runtime generic type checks and variance.
void main() {
List
numbers = [1, 2, 3];
print(numbers is List
); // true
}3.4. Closures
Explains lexical scoping and closure behavior in Dart, showing stateful closures that retain variables across calls.
void main() {
var printer = () {
int num = 0;
return () => print(++num);
}();
printer(); // 1
printer(); // 2
}3.5. Mixins
Shows how mixins can be used to compose behavior without inheritance, and how method resolution order works.
mixin A { void printName() => print("A"); }
class D with A, B, C { void printName() => super.printName(); }3.6. Extensions
Demonstrates Dart extensions to add methods to existing types without modifying the original class.
extension StringExtensions on String {
bool get isNotEmpty => this.isNotEmpty;
}3.7. Cascade (.. ) and Spread (... ) Operators
Shows practical uses of the cascade operator for method chaining and the spread operator for list concatenation.
var obj = MyClass()
..property = 'value'
..method1()
..method2();
var list2 = [0, ...list1];4. Encapsulation Thought & Practice
4.1. Original Implementation
Provides a simple Flutter app that directly uses Dio inside UI widgets, highlighting tight coupling between UI and data layers.
4.2. ApiClient
Introduces a singleton ApiClient that configures Dio with interceptors, logging, and optional proxy support.
class ApiClient {
late final Dio _dio;
static ApiClient? _instance;
ApiClient._internal(this._dio) {
_dio.interceptors.add(DefaultInterceptorsWrapper());
if (kDebugMode) {
_dio.interceptors.add(LogInterceptor(responseBody: true, requestBody: true));
_dio.httpClientAdapter = IOHttpClientAdapter(createHttpClient: localProxyHttpClient);
}
}
static Future
init(String baseUrl, {Map
? requestHeaders}) async {
_instance ??= ApiClient._internal(Dio(BaseOptions(
baseUrl: baseUrl,
responseType: ResponseType.json,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: requestHeaders ?? await _defaultRequestHeaders,
validateStatus: (status) => true,
)));
}
static ApiClient get instance {
if (_instance == null) throw Exception('ApiClient not initialized');
return _instance!;
}
Future
> get
(String endpoint, {Map
? queryParameters, Options? options, CancelToken? cancelToken}) {
return _dio.get
(endpoint, queryParameters: queryParameters, options: options, cancelToken: cancelToken);
}
Future
> post
(String endpoint, {dynamic data, Map
? queryParameters, Options? options, CancelToken? cancelToken}) {
return _dio.post
(endpoint, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken);
}
}4.3. API Service & UI Auto‑Refresh
Shows how to place API endpoints in a separate service file and use Riverpod's @riverpod annotation to generate providers that automatically rebuild UI when data changes.
@riverpod
Future
testGet(TestGetRef ref) => ApiClient.instance.get("/testGet");
@riverpod
Future
testPost(TestPostRef ref, int page) => ApiClient.instance.post("/testPost", data: {"page": page, "keyword": "${DateTime.now().millisecondsSinceEpoch}"});Explains handling of null values during refresh and demonstrates using NotifierProvider for finer‑grained state control.
4.4. Data Parsing & Error Handling
Defines a hierarchy of custom ApiException classes to represent network, HTTP, and business‑logic errors, and shows how to parse JSON responses into typed DataResponse or ListResponse objects.
class ApiException implements Exception {
final int? code;
final String? message;
String? stackInfo;
ApiException([this.code, this.message]);
// factory constructors for different DioException types omitted for brevity
}
class BadRequestException extends ApiException { BadRequestException(super.code, super.message); }
// other specific exception classes omittedThe ApiClient provides generic get and post methods that accept a fromJsonT closure to deserialize the response.
Future
get
(String endpoint, {D Function(dynamic json)? fromJsonT, Map
? queryParameters, Options? options, CancelToken? cancelToken}) =>
_performRequest
(() => _dio.get(endpoint, queryParameters: queryParameters, options: options, cancelToken: cancelToken), fromJsonT);Shows two error‑handling strategies: throwing exceptions that Riverpod captures as AsyncError , or returning a default object with an embedded ApiException field.
5. Summary
Flutter disables reflection, making runtime code generation limited, but the article provides a practical baseline for encapsulating network calls, integrating Riverpod for reactive UI, and structuring code with repositories and providers. Readers are encouraged to extend the demo with Retrofit, additional interceptors, or more sophisticated state management.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.