likes
comments
collection
share

你的 Flutter 项目异常太多是因为代码没有这样写

作者站长头像
站长
· 阅读数 30

以前在团队里 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 { 
  //...
}
转载自:https://juejin.cn/post/7375882178012577802
评论
请登录