Best Practices for Robust Dart and Flutter Development: Uri, Type Conversion, List Handling, ChangeNotifier, and Resource Management
This article presents a comprehensive guide to writing safer and more maintainable Flutter code by demonstrating proper Uri construction, type checking with 'is' versus 'as', safe List operations using the collection package, disciplined use of ChangeNotifier and its providers, and systematic resource disposal patterns.
Having reviewed countless code snippets and resolved numerous production bugs, the author shares practical advice to improve the robustness of Dart and Flutter applications.
Using the Uri Class
Instead of concatenating strings to build URLs, use the Uri class which automatically handles encoding and prevents malformed addresses.
/// Assume
outsideInput
comes from an external system and may contain illegal characters.
final someAddress = 'https://www.special.com/?a=
\\${Uri.encodeFull(outsideInput)}';
// When multiple parameters are needed:
final someAddress = 'https://www.special.com/?a=
\\${Uri.encodeFull(outsideInput)}&b=
\\${Uri.encodeFull(outsideInput1)}&c=
\\${Uri.encodeFull(outsideInput2)}';
// Conditional building:
if (conditionA) {
someAddress = 'https://www.special.com/?a=
\\${Uri.encodeFull(outsideInput)}';
} else if (conditionB) {
someAddress = 'https://www.special.com/?a=
\\${Uri.encodeFull(outsideInput)}&b=
\\${otherVariable}';
} else {
someAddress = 'https://www.special.com';
}
// Preferred way – construct a Uri object:
final someAddress = Uri(
path: 'some/sys/path/loc',
queryParameters: {
'a': '\\${outsideInput}', // automatically percent‑encoded
'b': '\\${outsideInput1}',
if (conditionA) 'c': '\\${outsideInput2}',
},
);Type Conversion
Use the is operator for safe type checking; it performs an implicit cast without throwing when the check fails, whereas as throws an exception on failure.
class Animal {
void eat(String food) => print('eat $food');
}
class Bird extends Animal {
void fly() => print('flying');
}
void main() {
Object animal = Bird();
if (animal is Bird) {
animal.fly(); // implicit cast
}
(animal as Animal).eat('meat'); // explicit cast, may throw
}Safe List Operations
Directly accessing first , last , or firstWhere on an empty list throws exceptions. Use the collection package helpers such as firstOrNull and firstWhereOrNull to avoid crashes.
import 'package:collection/collection.dart';
List
list = [];
list.firstOrNull; // null instead of exception
list.lastOrNull;
list.firstWhereOrNull((t) => t > 0);
list.singleOrNull;
list.lastWhereOrNull((t) => t > 0);
list.singleWhereOrNull((t) => t > 0);Alternatively, create a custom SafeList that returns default or placeholder values when an index is out of range.
class SafeList
extends ListMixin
{
final List
_rawList;
final T defaultValue;
final T absentValue;
SafeList({required this.defaultValue, required this.absentValue, List
? initList})
: _rawList = List.from(initList ?? []);
@override
T operator [](int index) => index < _rawList.length
? _rawList[index] ?? defaultValue
: absentValue;
@override
void operator []=(int index, T value) {
if (_rawList.length == index) {
_rawList.add(value);
} else {
_rawList[index] = value;
}
}
@override
int get length => _rawList.length;
@override
set length(int newLength) => _rawList.length = newLength;
@override
T get first => _rawList.isNotEmpty ? _rawList.first ?? defaultValue : absentValue;
@override
T get last => _rawList.isNotEmpty ? _rawList.last ?? defaultValue : absentValue;
}
final list = SafeList(defaultValue: 0, absentValue: 100, initList: [1, 2, 3]);
print(list[0]); // 1
print(list[3]); // 100 (out‑of‑range)
list.length = 101;
print(list[100]); // 0 (default after length change)ChangeNotifier Best Practices
After dispose() a ChangeNotifier must not be used. Guard all accesses with a disposed flag, preferably via a mixin.
mixin Disposed on ChangeNotifier {
bool _disposed = false;
@override
bool get hasListeners => !_disposed && super.hasListeners;
@override
void notifyListeners() {
if (_disposed) return;
super.notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class PageNotifier extends ChangeNotifier with Disposed {
dynamic pageData;
Future
beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
notifyListeners(); // safe – will be ignored after dispose
}
}Never reuse a single ChangeNotifier instance across unrelated widgets; instead provide it through Provider or get_it at the top of the widget tree.
void main() {
runApp(
MultiProvider(
providers: [
Provider
.value(value: ShoppingCart.instance),
],
child: const MyApp(),
),
);
}ChangeNotifierProvider Usage
When using ChangeNotifierProvider.value , you must manually dispose the passed instance because the provider does not own its lifecycle. Use the builder constructor for automatically managed lifecycles.
// Correct – manual disposal
MyChangeNotifier variable;
void initState() {
super.initState();
variable = MyChangeNotifier();
}
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: variable,
child: ...,
);
}
void dispose() {
variable.dispose();
super.dispose();
}
// Incorrect – let provider create the instance but pass a pre‑built one
return ChangeNotifierProvider(
create: (_) => variable, // leads to lifecycle confusion
child: ...,
);Resource Disposal Discipline
Many Flutter objects (e.g., Timer , StreamSubscription , ScrollController , TextEditingController ) require explicit disposal. Place dispose() as the first method after the field declarations and keep initState() immediately after, so reviewers can quickly verify that every created resource is released.
// Bad – dispose far from initState
final _controller = TextEditingController();
late Timer _timer;
void initState() {
super.initState();
_timer = Timer(...);
}
void dispose() {
_timer.cancel();
super.dispose();
} // Good – dispose placed before initState
void dispose() {
_controller.dispose();
_timer.cancel();
super.dispose();
}
void initState() {
super.initState();
_timer = Timer(...);
}A mixin can automate disposal of arbitrary callbacks:
mixin AutomaticDisposeMixin
on State
{
final Set
_disposeSet = {};
void autoDispose(VoidCallback cb) => _disposeSet.add(cb);
@override
void dispose() {
for (final cb in _disposeSet) cb();
_disposeSet.clear();
super.dispose();
}
}
class _PageState extends State
with AutomaticDisposeMixin {
Future
_refreshPage() async {
final token = CancelToken();
autoDispose(() => token.cancel());
final response = await Dio().get(url, cancelToken: token);
// use response …
}
}StatefulWidget Asynchronous Refresh
When an asynchronous operation updates UI, always check mounted before calling setState to avoid exceptions if the widget has been removed from the tree.
Future
_refreshPage() async {
final response = await API.getPageDetail();
if (!response.success) return;
if (!mounted) return; // guard
setState(() {
_data = response.data;
});
}For convenience, a mixin can override setState to perform this guard automatically.
mixin Stateable
on State
{
@override
void setState(VoidCallback fn) {
if (!mounted) return;
super.setState(fn);
}
}
class SomePageState extends State
with Stateable {
// async UI updates are now safe without repetitive checks
}By following these guidelines—using Uri objects, preferring is over as , leveraging the collection package, managing ChangeNotifier lifecycles, systematically disposing resources, and guarding asynchronous UI updates—you can write Flutter code that is more robust, maintainable, and less error‑prone.
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.