你的 Flutter 项目异常太多是因为代码没有这样写
以前在团队里 Review 过无数代码,也算是阅码无数的人了,解决过的线上异常也数不胜数,故对如何写出健状性的代码有一些微小的见解。刚好最近闲来得空(其实是拖延症晚期)把之前躺在备忘录里的一些小记整理了一下,希望能对你有一些启发。
Uri 对象的使用
在 Dart 语言中 Uri 类用于表示 URIs(网络地址、文件地址或者路由),它内部能自动处理地址的模式与的百分号编解码。在实际开发过程我们经常直接使用字符串进行拼接 URIs,然而这种方式会给地址的高级处理带来不便甚至隐式异常。
/// 假设当前代码为一个内部系统,[outsideInput] 变量是外部系统传入内部的字符串变量
/// 你无法限定 [outsideInput] 的内容,如果变量包含非法字符(如中文),整个地址非法
final someAddress = 'https://www.special.com/?a=${outsideInput}';
/// 为了保持 URI 的完整性你可能会这样做
final someAddress = 'https://www.special.com/?a=${Uri.decodeFull(outsideInput)}';
/// 如有多个外部输入变量你又需要这样做
final someAddress = 'https://www.special.com/?a=${Uri.encodeFull(outsideInput)}&b=${Uri.encodeFull(outsideInput1)}&c=${Uri.encodeFull(outsideInput2)}';
直接使用字符串来拼接 URI 地址会带来非常多的限制,你需要关注地址中拼接的每个部分的合法性,并且在处理复杂逻辑时需要更冗长为的处理。
/// 如果我们需要在另一个系统中对 [someAddress] 地址的参数按条件进行添加
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';
}
如果使用 Uri 可以简化绝大多数对 URIs 的处理,同时限定类型对外部有更明确的确定性,因此针对 URIs 需要做如下约定:
在任何系统中都不应直接拼接 URIs 字符串,应当构造 URI 对象作为参数或返回值。
/// 生成 Uri 对象
final someAddress = Uri(path: 'some/sys/path/loc', queryParameters: {
'a': '${outsideInput}', // 非法参数将自动百分号编码
'b': '${outsideInput1}', // 不用对每个参数单独进行编码
if (conditionA) 'c': '${outsideInput2}', // 条件参数更为简洁
});
类型转换
Dart 中可以使用 is
进行类型判断,as
进行类型转换。 同时,使用 is
进行类型判断成功后会进行隐性的类型转换。示例如下:
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(); // 隐式类型转换
}
(animal as Animal).eat('meat'); // 强制类型转换一旦失败就会抛异常
}
由于隐式的类型转换存在,is
可以充当 as
的功能,同时 as
进行类型失败会抛出异常。
所以日常开发中建议使用 is
而不是 as
来进行类型转换。 is
运算符允许更安全地进行类型检查,如果转换失败,也不会抛出异常。
void main() {
dynamic animal = Bird();
if (animal is Bird) {
animal.fly();
} else {
print('转换失败');
}
}
List 使用
collection package 的使用
List 作为 Dart 中的基础对象使用广范,由于其本身的特殊性,如使用不当极易导致异常,从而影响业务逻辑。典型示例如下:
List<int> list = [];
// 当 List 为空时访问其 first 会抛异常
list.first
// 同理访问 last 也会抛异常
list.last
// 查找对象时没有提供 orElse 也会抛异常
list.firstWhere((t) => t > 0);
// List 对象其它会抛异常的访问还有
list.single
list.lastWhere((t) => t > 0)
list.singleWhere((t) => t > 0)
所以如果没有前置判断条件,所有对 List 的访问均需替换为 collection
里对应的方法。
import 'package:collection/collection.dart';
List<int> list = [];
list.firstOrNull;
list.lastOrNull;
list.firstWhereOrNull((t) => t > 0);
list.singleOrNull;
list.lastWhereOrNull((t) => t > 0);
list.singleWhereOrNull((t) => t > 0);
取元素越界
在 Dart 开发时,碰到数组越界或者访问数组中不存在的元素情况时,会导致运行时错误,如:
List<int> numbers = [0, 1, 2];
print(numbers[3]); // RangeError (index): Index out of range: index should be less than 3: 3
你可以使用使用 try-catch
来捕获异常,但由于数组取值这样的基础操作往往遍布在项目的各个角落,try-catch
在这样的情况下使用起来会比较繁琐,而且并不是所有的取值都会导致异常,所以往往越界的问题只有真正出现了才发现。
好在,我们可以封装一个 extension
来简化数组越界的问题:
extension SafeGetList<T> on List<T> {
T? tryGet(int index) =>
index < 0 || index >= this.length ? null : this[index];
}
使用时:
final list = <int>[];
final single = list.tryGet(0) ?? 0;
由于 tryGet
返回值类型为可空(T?
) ,外部接收时需要进行空判断或者赋默认值,这相当于强迫开发者去思考值不存在的情况,如此减少了异常发生的可能,同时在业务上也更加严谨。
当然还有另一种方案,可以继承一个 ListMixin
的自定义类:SafeList
,其代码如下:
class SafeList<T> extends ListMixin<T> {
final List<T?> _rawList;
final T defaultValue;
final T absentValue;
SafeList({
required this.defaultValue,
required this.absentValue,
List<T>? 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
T get first => _rawList.isNotEmpty ? _rawList.first ?? defaultValue : absentValue;
@override
T get last => _rawList.isNotEmpty ? _rawList.last ?? defaultValue : absentValue;
@override
set length(int newValue) {
_rawList.length = newValue;
}
}
使用:
final list = SafeList(defaultValue: 0, absentValue: 100, initList: [1,2,3]);
print(list[0]); // 正常输出: 1
print(list[3]); // 越界,输出缺省值: 100
list.length = 101;
print(list[100]); // 改变数组长度了,输出默认值: 0
以上两种方案均可以解决越界的问题,第一个方案更简洁,第二个方案略复杂且侵略性也更强但好处是可以统一默认值、缺省值,具体使用哪种取决于你的场景。
ChangeNotifier 使用
ChangeNotifier 的属性访问或方法调用
ChangeNotifier
及其子类在 dispose
之后将不可使用,dispose
后访问其属性(hasListener
)或方法(notifyListeners
)时均不合法,在 Debug 模式下会触发断言异常;
// ChangeNotifier 源码
bool get hasListeners {
// 访问属性时会进行断言检查
assert(ChangeNotifier.debugAssertNotDisposed(this));
return _count > 0;
}
void dispose() {
assert(ChangeNotifier.debugAssertNotDisposed(this));
assert(() {
// dispose 后会设置此标志位
_debugDisposed = true;
return true;
}());
_listeners = _emptyListeners;
_count = 0;
}
static bool debugAssertNotDisposed(ChangeNotifier notifier) {
assert(() {
if (notifier._debugDisposed) { // 断言检查是否 dispose
throw FlutterError(
'A ${notifier.runtimeType} was used after being disposed.\n'
'Once you have called dispose() on a ${notifier.runtimeType}, it '
'can no longer be used.',
);
}
return true;
}());
return true;
}
在 dispose
后访问属性或调用方法通常出现在异步调用的场景下,由其是在网络请求之后刷新界面。典型场景如下:
class PageNotifier extends ChangeNotifier {
dynamic pageData;
Future<voud> beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
// 接口返回之后此实例可能被 dispose,从而导致异常
notifyListeners();
}
}
为使代码逻辑更加严谨,增强整个代码的健状性:
ChangeNotifier
在有异步的场景情况下,所有对 ChangeNotifier
属性及方法的访问都需要进行是否 dispose
的判断。
你可能会想到加一个 hasListeners
判断:
class PageNotifier extends ChangeNotifier {
dynamic pageData;
Future<voud> beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
// Debug 模式下 hasListeners 依然可能会抛异常
if (hasListeners) notifyListeners();
}
}
如上所述 hasListeners
内部仍然会进行是否 dispose
的断言判断,所以 hasListeners
仍然不安全。
因此正确的做法是:
// 统一定义如下 mixin
mixin Disposed on ChangeNotifier {
bool _disposed = false;
bool get hasListeners {
if (_disposed) return false;
return super.hasListeners;
}
@override
void notifyListeners() {
if (_disposed) return;
super.notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
// 在必要的 ChangeNotifier 子类混入 Disposed
class PageNotifier extends ChangeNotifier with Disposed {
Future<voud> beginRefresh() async {
final response = await API.getPageContent();
if (!response.success) return;
pageData = response.data;
// 异步调用不会异常
notifyListeners();
}
}
ChangeNotifier 禁止实例复用
ChangeNotifier
在各种状态管理模式中一般都用于承载业务逻辑,初入 Flutter 的开发者会受原生开发的思维模式影响可能会将 ChangeNotifier
实例进行跨组件复用。典型的使用场景是购物车,购物车有加/减商品、数量管理、折扣管理、优惠计算等复杂逻辑,将 ChangeNotifier
单个实例复用甚至单例化能提高编码效率。
但单个 ChangeNotifier
实例在多个独立的组件或页面中使用会造成潜在的问题:复用的实例一旦在某个组件中被意外 dispose
之后就无法使用,从而影响其它组件展示逻辑并且这种影响是全局的。
@override
void initState() {
super.initState();
// 添加监听
ShoppingCart.instance.addListener(_update);
}
@override
void dispose() {
// 正确移除监听
ShoppingCart.instance.removeListener(_update);
// 如果哪个实习生不小心在组件中这样移除监听,将产生致命影响
// ShoppingCart.instance.dispose();
super.dispose();
}
因此在 Flutter 开发中应禁止 ChangeNotifier
实例对外跨组件直接复用,如需跨组件复用应借助provider
、get_it
等框架将 ChangeNotifer
子类实例对象置于顶层;
void main() {
runApp(
MultiProvider(
providers: [
Provider<Something>.value(ShoppingCart.instance),
],
child: const MyApp(),
)
);
}
如果你非得要 「单例化」 自定义 ChangeNotifier
子类实例,记得一定要重新 dispose
函数。
Controller 使用
在 Flutter 中大多数 Controller
都直接或间接继承自 ChangeNotifier
。为使代码逻辑更加严谨,增强整个代码的健状性,建议:
所有 Controller
需要显式调用 dispose
方法,所有自定义 Controller
需要重写或者添加 dispose
方法。
// ScrollController 源码
class ScrollController extends ChangeNotifier {
//...
}
// 自定义 Controller 需要添加 dispose 方法
class MyScrollController {
ScrollController scroll = ScrollController();
// 添加 dispose 方法
void dispose() {
scroll.dispose();
}
}
ChangeNotifierProvider 使用
ChangeNotifierProvider
有两个构造方法:
ChangeNotifierProvider.value({value:})
ChangeNotifierProvider({builder:})
使用 value
构造方法时需要注意:value
传入的是一个已构造好的 ChangeNotifier
子类实例,此实例不由 Provider
内构建,Provider
不负责此实例的 dispose
。
虽然这个差异在 Provider 文档中有重点说明,但仍然有不少开发人员在写代码的过程中混用,故在此再次强调
因此开发人员在使用 ChangeNotifierProvider.value
时为使代码逻辑更加严谨,增强整个代码的健状性,培养良好的开发习惯开发人员需践行以下规范:
使用 ChangeNotifierProvider.value
构造方法时传入的实例一定是一个已构建好的实例,你有义务自行处理此实例的 dispose
。使用 ChangeNotifierProvider(builder:)
构造方法时你不应该传入一个已构建好的实例,这会导致生命周期混乱,从而导致异常。
你需要这样做
MyChangeNotifier variable;
void initState() {
super.initState();
variable = MyChangeNotifier(); // 提前构建实例
}
void build(BuildContext context) {
return ChangeNotifierProvider.value(
value: variable, // 已构建好的实例
child: ...
);
}
void dispose() {
super.dispose();
variable.dispose(); // 主动 dispose
}
你不能这样做
MyChangeNotifier variable;
void initState() {
super.initState();
variable = MyChangeNotifier();
}
void build(BuildContext context) {
// create 对象的生命周期只存在于 Provider 树下,此处应不直接使用此实例
return ChangeNotifierProvider(
create: (_) => variable,
child: ...
);
}
避免资源释放遗忘
在 Flutter 中有很多需要主动进行资源释放的类型,包含但不限于:Timer
、StreamSubscription
、ScrollController
、TextEditingController
等,另外很多第三方库存在需要进行资源释放的类型。
如此多的资源释放类型管理起来是非常麻烦的,一旦忘记某个类型的释放很会造成整个页面的内存泄漏。而资源的创建一般都位于 initState
内,资源释放都位于 dispose
内。
为了减小忘记资源释放的可能性,dispose
应为 State
内的第一个函数并尽可能的将 initsate
紧跟在 dispose
后
这样在代码 Review 时可以从视觉上一眼看出来资源释放是否被遗忘。
Bad
final _controller = TextEditingController();
late Timer _timer;
void initState() {
super.initState();
_timer = Timer(...);
}
Widget build(BuildContext context) {
return SizedBox(
child: // 假设此处为简单的登录界面,也将是一串很长的构建代码
);
}
void didChangeDependencies() {
super.didChangeDependencies();
// 又是若干行
}
// dispose 函数在 State 末尾,与 initState 大概率会超过一屏的距离
// 致使 dispose 需要释放的资源与创建的资源脱节
// 无法直观看出是否漏写释放函数
void dispose() {
_timer.cancell();
super.dispose();
}
Good
final _controller = TextEditingController();
late Timer _timer;
// 属性后第一个函数应为 dispose
void dispose() {
_controller.dispose();
_timer.cancell();
super.dispose();
}
// 中间不要插入其它函数,紧跟着写 initState
void initState() {
super.initState();
_timer = Timer(...);
}
上面推荐的写法也可以用在自定义的 ChangeNotifer
子类中,将 dispose
函数紧在构造函数后,有利于释放遗漏检查。
由于创建资源与释放资源在不同的函数内,因此存在一种情况:为了释放资源不得不在 State
内加一个变量以便于在 dipose
函数中引用并释放,即便此资源仅在局部使用。
典型场景如下:
late CancelToken _token;
Future<void> _refreshPage() async {
// _token 只在页面刷新的函数中使用,却不得不加一个变量来引用它
_token = CancelToken();
Dio dio = Dio();
Response response = await dio.get(url, cancelToken: _token);
int code = response.statusCode;
// ...
}
void dispose() {
super.dispose();
_token.cancel();
}
这样的场景在一个页面内可能有多处,相同的处理方式使用起来就略显麻烦了,也容易导致遗忘。因此推荐如下写法:
// 创建下面的 Mixin
mixin AutomaticDisposeMixin<T extends StatefulWidget> on State<T> {
Set<VoidCallback> _disposeSet = Set<VoidCallback>();
void autoDispose(VoidCallback callabck) {
_disposeSet.add(callabck);
}
void dispose() {
_disposeSet.forEach((f) => f());
_disposeSet.removeAll();
super.dispose();
}
}
class _PageState extends State<Page> with AutomaticDisposeMixin {
Future<void> _refreshPage() async {
final token = CancelToken();
// 添加到自动释放队列
autoDispose(() => token.cancel());
Dio dio = Dio();
Response response = await dio.get(url, cancelToken: token);
int code = response.statusCode;
// ...
}
}
当然也这种用法不限于局部变量,同样也可以在 initState
内进行资源声明的同时进行资源释放,这种写法相对来讲更加直观,更不易遗漏资源释放。
final _controller = TextEditingController();
void initState() {
super.initState();
_timer = Timer(...);
autoDispose(() => _timer.cancel());
autoDispose(() => _controller.dispose());
}
StatefulWidget 使用
State 中存在异步刷新
在开发过程中简单的页面或组件通常直接使用 StatefulWidget
进行构建,并在 State
中实现状态逻辑。因此 State
不可避免可能会存在异步刷新的场景。但异步结束时当前 Widget 可能已经从当前渲染树移除,直接刷新当前 Widget 可能导致异常。典型示例如下:
class SomPageState extends State<SomePageWidget> {
PageData _data;
Future<void> _refreshPage() async {
// 异步可能是延时、接口、文件读取、平台状态获取等
final response = await API.getPageDetaile();
if (!response.success) return;
// 直接界面刷新页面可能会导致异常,当前 Widget 可能已从渲染树移除
setState((){
_data = response.data;
});
}
}
为使代码逻辑更加严谨,增强整个代码的健壮性,培养良好的开发习惯,建议:
在 State
里异步刷新 UI 时需要进行 mounted
判断,确认当前 Widget
在渲染树中时才需要进行界面刷新否则应忽略。
Future<void> _refreshPage() async {
// 异步可能是接口、文件读取、状态获取等
final response = await API.getPageDetaile();
if (!response.success) return;
// 当前 Widget 存在于渲染树中才刷新
if (!mounted) return;
setState((){
_data = response.data;
});
}
上面的 mounted
判断可能会存在于所有 State
中又或者一个 State
里有多个异步 setState
调用,每个调用都去判断过于繁锁,因此更推荐如下写法:
// 统一定义如下 mixin
mixin Stateable<T extends StatefulWidget> on State<T> {
@override
void setState(VoidCallback fn) {
if (!mounted) return;
super.setState(fn);
}
}
// 在存在异步刷新的 State 中 with 如上 mixin
class SomPageState extends State<SomePageWidget> with Stateable {
//...
}
来源:juejin.cn/post/7375882178012577802