注册

你的 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 实例对外跨组件直接复用,如需跨组件复用应借助providerget_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 中有很多需要主动进行资源释放的类型,包含但不限于:TimerStreamSubscriptionScrollControllerTextEditingController等,另外很多第三方库存在需要进行资源释放的类型。


如此多的资源释放类型管理起来是非常麻烦的,一旦忘记某个类型的释放很会造成整个页面的内存泄漏。而资源的创建一般都位于 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

0 个评论

要回复文章请先登录注册