哔哩哔哩漫画APP实践Flutter也有大半年时间了,我针对线上收集到的错误进行分析,挑选出了一些有一般代表性的错误,列在本文,可供实践 Flutter 的初学者们作为一点参考。
典型错误一:无法掌握的Future
典型错误信息:NoSuchMethodError: The method 'markNeedsBuild' was called on null.
这个错误常出现在异步任务(Future)处理,比如某个页面请求一个网络API数据,根据数据刷新 Widget State。
异步任务结束在页面被pop之后,但没有检查State 是否还是 mounted
,继续调用 setState
就会出现这个错误。
示例代码
一段很常见的获取网络数据的代码,调用 requestApi()
,等待Future从中获取response
,进而setState
刷新 Widget:
1 2 3 4 5 6 7 8 9 10 | class AWidgetState extends State<AWidget> { // ... var data; void loadData() async { var response = await requestApi(...); setState((){ this.data = response.data; }) } } |
原因分析
response
的获取为async-await
异步任务,完全有可能在AWidgetState
被 dispose
之后才等到返回,那时候和该State
绑定的 Element
已经不在了。故而在setState
时需要容错。
解决办法: setState
之前检查是否 mounted
1 2 3 4 5 6 7 8 9 10 11 12 | class AWidgetState extends State { // ... var data; void loadData() async { var response = await requestApi(...); if (mounted) { setState((){ this.data = response.data; }) } } } |
这个mounted
检查很重要,其实只要涉及到异步还有各种回调(callback),都不要忘了检查该值。
比如,在 FrameCallback
里执行一个动画(AnimationController):
1 2 3 4 5 6 | @override void initState(){ WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _animationController.forward(); }); } |
AnimationController
有可能随着 State 一起 dispose
了,但是FrameCallback
仍然会被执行,进而导致异常。
又比如,在动画监听的回调里搞点事:
1 2 3 4 5 6 7 8 | @override void initState(){ _animationController.animation.addListener(_handleAnimationTick); } void _handleAnimationTick() { if (mounted) updateWidget(...); } |
同样的在_handleAnimationTick
被回调前,State 也有可能已经被dispose
了。
如果你还不理解为什么,请仔细回味一下Event loop
还有复习一下 Dart 的线程模型。
典型错误二:Navigator.of(context) 是个 null
典型错误信息:NoSuchMethodError: The method 'pop' was called on null.
常在 showDialog
后处理 dialog 的 pop() 出现。
示例代码
在某个方法里获取网络数据,为了更好的提示用户,会先弹一个 loading 窗,之后再根据数据执行别的操作…
1 2 3 4 5 6 7 8 9 10 11 12 13 | // show loading dialog on request data showDialog<void>( context: context, barrierdismissible: false, builder: (_) { return Center( child: CircularIndicator(), ); }, ); var data = (await requestApi(...)).data; // got it, pop dialog Navigator.of(context).pop(); |
原因分析:
出错的原因在于—— Android 原生的返回键:虽然代码指定了barrierdismissible: false
,用户不可以点半透明区域关闭弹窗,但当用户点击返回键时,Flutter 引擎代码会调用 NavigationChannel.popRoute()
,最终这个 loading dialog 甚至包括页面也被关掉,进而导致Navigator.of(context)
返回的是null
,因为该context
已经被unmount
,从一个已经凋零的树叶上是找不到它的根的,于是错误出现。
另外,代码里的Navigator.of(context)
所用的context
也不是很正确,它其实是属于showDialog
调用者的而非 dialog 所有,理论上应该用builder
里传过来的context
,沿着错误的树干虽然也能找到根,但实际上不是那么回事,特别是当你的APP里有Navigator
嵌套时更应该注意。
解决办法
首先,确保 Navigator.of(context)
的 context
是 dialog 的context
;
其次,在 Dialog Widget 外层可以包一个 WillPopScope
处理返回键,或者保险起见检查 null
,以应对被手动关闭的情况。
showDialog
时传入 GlobalKey
,通过 GlobalKey
去获取正确的context
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | GlobalKey key = GlobalKey(); showDialog<void>( context: context, barrierdismissible: false, builder: (_) { // disallow pop by 'back' button when barrierdismissible is false return WillPopScope( onWillPop: () => Future.value(false), child: KeyedSubtree( key: key, child: Center( child: CircularIndicator(), ) ) ); }, ); var data = (await requestApi(...)).data; if (key.currentContext != null) { Navigator.of(key.currentContext)?.pop(); } |
key.currentContext
为null
意为着该 dialog 已经被dispose
,亦即已经从 WidgetTree 中unmount
。
其实,类似的XXX.of(context)
方法在 Flutter 代码里很常见,比如 MediaQuery.of(context)
、Theme.of(context)
、DefaultTextStyle.of(context)
,DefaultAssetBundle.of(context)
等等,都要注意传入的context
是来自正确节点的,否则会有惊喜在等你。
写 Flutter 代码时,脑海里一定要对context
的树干脉络有清晰的认知,如果你还不是很理解context
,可以看看 《深入理解BuildContext》 - Vadaski。
典型错误三:ScrollController 里薛定谔的 position
在获取ScrollController
的position
、offset
,或者调用jumpTo()
等方法时,常出现StateError
错误。
错误信息:StateError Bad state: Too many elements
,StateError Bad state: No element
示例代码
在某个按钮点击后,通过ScrollController
控制ListView
滚动到开头:
1 2 3 4 5 | final ScrollController _primaryScrollController = ScrollController(); // 回到开头 void _handleTap() { if(_primaryScrollController.offset > 0) _primaryScrollController.jumpTo(0.0) } |
原因分析
先看ScrollController
的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class ScrollController extends ChangeNotifier { //... @protected Iterable<ScrollPosition> get positions => _positions; final List<ScrollPosition> _positions = <ScrollPosition>[]; double get offset => position.pixels; ScrollPosition get position { assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.'); assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.'); return _positions.single; } //... } |
很明显,ScrollController
的 offest
是从 position
中获得,而position
则是来自变量 _positions
。
StateError
错误,就是_positions.single
这一行抛出:
1 2 3 4 5 6 7 8 9 10 11 | abstract class Iterable<E> { //... E get single { Iterator<E> it = iterator; if (!it.moveNext()) throw IterableElementError.noElement(); E result = it.current; if (it.moveNext()) throw IterableElementError.tooMany(); return result; } //... } |
那么问题来了,这个_positions
为什么忽而一滴不剩,忽而却嫌它给的太多了呢?ˊ_>ˋ
还是要回到 ScrollController
的源码里找找。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class ScrollController extends ChangeNotifier { // ... void attach(ScrollPosition position) { assert(!_positions.contains(position)); _positions.add(position); position.addListener(notifyListeners); } void detach(ScrollPosition position) { assert(_positions.contains(position)); position.removeListener(notifyListeners); _positions.remove(position); } } |
-
为什么没有数据(No element):
ScrollController
还没有attach
一个position
。原因有两个:一个可能是还没被 mount 到树上(没有被Scrollable
使用到);另外一个就是已经被detach
了。 -
为什么多了(Too many elements):
ScrollController
还没来得及detach
旧的position
,就又attach
了一个新的。原因多半是因为ScrollController
的用法不对,同一时间被多个Scrollable
关注到了。
解决办法
针对 No element 错误,只需判断一下 _positions
是不是空的就行了,即hasClients
。
1 2 3 4 5 | final ScrollController _primaryScrollController = ScrollController(); // 回到开头 void _handleTap() { if(_primaryScrollController.hasClients && _primaryScrollController.offset > 0) _primaryScrollController.jumpTo(0.0) } |
针对 Too many elements 错误,确保ScrollController
只会被一个 Scrollable
绑定,别让它劈腿了,且被正确 dispose()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class WidgetState extends State { final ScrollController _primaryScrollController = ScrollController(); @override Widget build(BuildContext context) { return ListView.builder( controller: _primaryScrollController, itemCount: _itemCount, itemBuilder: _buildItem, ) } int get _itemCount => ...; Widget _buildItem(context, index) => ...; @override void dispose() { super.dispose(); _primaryScrollController.dispose(); } } |
典型错误四:四处碰壁 null
Dart 这个语言可静可动,类型系统也独树一帜。万物都可以赋值null
,就导致写惯了 Java 代码的同志们常常因为bool
int
double
这种看起来是”primitive”的类型被null
附体而头晕。
典型错误信息:
Failed assertion: boolean expression must not be null
NoSuchMethodError: The method '>' was called on null.
NoSuchMethodError: The method '+' was called on null.
NoSuchMethodError: The method '*' was called on null.
示例代码
这种错误,较常发生在使用服务端返回的数据model时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | class StyleItem { final String name; final int id; final bool hasNew; StyleItem.fromJson(Map<String, dynamic> json): this.name = json['name'], this.id = json['id'], this.hasNew = json['has_new']; } StyleItem item = StyleItem.fromJson(jsonDecode(...)); Widget build(StyleItem item) { if (item.hasNew && item.id > 0) { return Text(item.name); } return SizedBox.shrink(); } |
原因分析
StyleItem.fromJson()
对数据没有容错处理,应当认为 map 里的value都有可能是 null
。
解决办法:容错
1 2 3 4 5 6 7 8 9 10 | class StyleItem { final String name; final int id; final bool hasNew; StyleItem.fromJson(Map<String, dynamic> json): this.name = json['name'], this.id = json['id'] ?? 0, this.hasNew = json['has_new'] ?? false; } |
一定要习惯 Dart 的类型系统,什么都有可能是null
,比如下面一段代码,你细品有几处可能报错:
1 2 3 4 5 6 7 8 9 10 11 12 13 | class Test { double fraction(Rect boundsA, Rect boundsB) { double areaA = boundsA.width * boundsA.height; double areaB = boundsB.width * boundsB.height; return areaA / areaB; } void requestData(params, void onDone(data)) { _requestApi(params).then((response) => onDone(response.data)); } Future<dynamic> _requestApi(params) => ...; } |
小提示,onDone()
也可以是null
>﹏<。
在和原生用 MethodChannel
传数据时更要特别注意,小心驶得万年船。
典型错误五:泛型里的 dynamic 一点也不 dynamic
典型错误信息:
type 'List<dynamic>' is not a subtype of type 'List<int>'
type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'Map<String, String>'
常发生在给某个List、Map 变量赋值时。
示例代码
这种错误,也较常发生在使用服务端返回的数据model时。
1 2 3 4 5 6 7 8 9 10 11 | class Model { final List<int> ids; final Map<String, String> ext; Model.fromJson(Map<String, dynamic> json): this.ids = json['ids'], this.ext= json['ext']; } var json = jsonDecode("""{"ids": [1,2,3], "ext": {"key": "value"}}"""); Model m = Model.fromJson(json); |
原因分析
jsonDecode()
这个方法转换出来的map的泛型是Map<String, dynamic>
,意为 value 可能是任何类型(dynamic),当 value 是容器类型时,它其实是List<dynamic>
或者Map<dynamic, dynamic>
等等。
而 Dart 的类型系统中,虽然dynamic
可以代表所有类型,在赋值时,如果数据类型事实上匹配(运行时类型相等)是可以被自动转换,但泛型里 dynamic
是不可以自动转换的。可以认为 List<dynamic>
和 List<int>
是两种运行时类型。
解决办法:使用 List.from, Map.from
1 2 3 4 5 6 7 8 | class Model { final List<int> ids; final Map<String, String> ext; Model.fromJson(Map<String, dynamic> json): this.ids = List.from(json['ids'] ?? const []), this.ext= Map.from(json['ext'] ?? const {}); } |
总结
综上所述,这些典型错误,都不是什么疑难杂症,而是不理解或者不熟悉 Flutter 和 Dart 语言所导致的,关键是要学会容错处理。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。