用脚步丈量人生宽度

Flutter 从零开始(二):Dart 语言速成,写给已经会一门语言的你

这一篇讲 Dart 本身。我是带着 Java 和一点 JS 的背景进 Flutter 的,头两周真正拖住我的不是 Widget 和布局,而是 Dart 里那些「看着眼熟但意思不一样」的东西:late、const、mixin、await 之后的 context。它们在教程里常常一句话带过,而我在实际代码里每一处都撞过一回。

下面按我自己的理解顺序梳理,尽量跟已有的语言经验做对照。同一件事如果 Java/JS 里做法不同,我会写出来——这些差异才是绊人的地方。

为什么 Dart 长成这个样子

Dart 的定位是「为 UI 而设计的语言」,这句话具体落到三个机制上。我自己是先把这三个理顺,后面看 Dart 语法才不拧巴。

AOT 和 JIT 同时存在。 上一篇提过:发布产物是 AOT 编译成机器码的,debug 期跑 JIT。这不是两套语言,是同一段源码在两个阶段用两种编译策略。热重载、表达式求值、observatory 调试都依赖 JIT 那一面;启动速度和滚动帧率依赖 AOT 那一面。

一个 isolate,一次只跑一件事。 Java 里我习惯共享内存加锁:字段随时可能被另一个线程改,所以可变状态要 synchronized。Dart 没有这些关键字,也不需要有——业务代码跑在单个 isolate 里,同一时刻只有一段代码在执行,另一段想插进来只能在当前这段让出控制权之后。UI 线程就是这个 isolate。

所以 Dart 里数据竞争这件事不存在,代价是你也没法「起个线程去算」。CPU 密集的活儿走 compute(),它另开一个 isolate,两边靠消息传递(对象要序列化),不共享内存。我第一次想把一段大 JSON 解析挪出主 isolate 时,直觉是找 Thread,实际答案只有 compute 和 Isolate.spawn。

让出控制权的点是语法可见的。 async 函数里 await 那一行就是「我到此为止,剩下的排进队列」。因为可见,所以读代码时能一眼看出哪里的状态可能被别的事件改掉——这点和 Java 里「任何一行都可能被别的线程打断」是个完全不同的思维负担。

有一条执行顺序是我实际用过的:

void main() {
  print('1 同步代码');
  Future(() => print('4 event queue:普通 Future / Future.delayed 都排这里'));
  scheduleMicrotask(() => print('3 microtask queue'));
  print('2 同步代码');
}
// 输出 1 2 3 4,跟代码写的先后无关

原因是事件循环的规则:一段同步代码跑完,先把 microtask 队列整个清空,再去 event queue 取下一个任务。await 后面那半截函数体是排进 microtask 的,所以 await 一个已经完成的 Future 也会让出当前这段同步执行——只是延后一点点,不是延后到下一帧。WidgetsBinding.instance.addPostFrameCallback 那类「等这一帧画完」的需求排的是 event queue,跟 microtask 不是一回事,我在这儿混淆过一次。

空安全:? ! ?. ??= 和 late 各干什么

Dart 的空安全和 Java 的 Optional、JS 的运行时 undefined 都不是一回事:它是编译期类型系统的一部分。String 和 String? 是两个类型,把 String? 传给要 String 的位置直接编译不过。

五个符号在我脑子里的分工是这样的:

String? name;             // 类型本身允许为 null

int len = name!.length;   // ! 是「我赌它不为 null」,编译期放行,赌错运行时炸
int? safe = name?.length; // ?. 一路短路:name 为 null 就整个表达式为 null,不会抛
String shown = name ?? '匿名';        // ?? 给兜底值
name ??= '从没有到有了';               // ??= 只在当前为 null 时才赋值

late String deferred;     // 推迟到首次访问时才要求有值,见下文

关键差别在于检查发生在什么时候:? 和 ?? 是编译期就完事的;! 是把检查推迟到运行时;late 也是推迟到运行时。

! 炸出来的样子:

Null check operator used on a null value

late 我一开始没当回事。它的用途有两个正当场景:一个是 State 的字段要在 initState() 里初始化、构造函数阶段给不出值;另一个是 late final 的懒计算(首次读才算)。它省下的是「构造时必须给初值」这条规则。

代价是它把空检查推到了第一次读取那一刻,而编译器不再帮你把关。我自己埋过一次雷:

class _DetailState extends State<Detail> {
  late final String _title;   // 声明了,指望 initState 里赋值

  @override
  void initState() {
    super.initState();
    // 这里本来该有 _title = widget.title;,重构时被我删了
  }

  @override
  Widget build(BuildContext context) => Text(_title);   // 炸在这行
}
LateInitializationError: Field '_title' has not been initialized.

报错本身很明确,坑在于它不在编译期出现,而是在某个交互路径第一次走到 build() 时才炸——那条路径恰好是异常分支,我本地点了很久才碰到。判断依据是错误里的字段名;我的处理方式是把这类字段改回可空(String? _title)并在读取处显式处理,或者保证它能在构造函数里给值。late 后面跟 final 比不跟安全一些:late final 只能被赋一次,重复赋值会报错;不 final 的 late 变量在初始化前被读到,同样抛 LateInitializationError。

final 和 const 的真实分界

这两个在 Java 里对应的分别是 final 和 static final + 编译期常量,但 Dart 的分界更硬:

  • final:运行时求值一次,之后引用不可变。值可以是函数调用结果。
  • const:编译期就是常量,必须是常量表达式,而且不能引用任何运行时才知道的东西。
class Order {
  final DateTime createdAt = DateTime.now();          // OK,运行时才有值
  // const DateTime createdAt = DateTime.now();       // 编译期不通过,DateTime.now() 不是常量表达式
  static const List<String> codes = ['CNY', 'USD'];   // 编译期常量,只能挂在 static 或局部上
}

final a = [1, 2, 3];
final b = [1, 2, 3];
const c = [1, 2, 3];
const d = [1, 2, 3];
print(identical(a, b)); // false —— 每次执行新建一个对象
print(identical(c, d)); // true  —— 编译期常量会被合并成同一个实例

identical(c, d) == true 这条不是趣闻,它在 Flutter 里有实际作用:Widget 是每次重建都换新的,const Text('你好') 因为整棵子树是编译期同一个对象,父级重建时 Flutter 直接判定没变、跳过重建。上一篇我在代码里写了一堆 const,原因在这里。

顺带一个我踩过的:const 集合是不可变的,const [1, 2].add(3) 会抛:

Unsupported operation: Cannot add to an unmodifiable list

这个报错的讨厌之处在于它跟 const 参数经常离得很远——某个默认值是 const [] 的参数被下游 add 了,栈顶看起来完全无关。判断方法我学会了看那行代码的容器字面量前面有没有 const。

函数当值用:可选参数的两种写法与闭包捕获

Dart 的参数有三种位置:必填、位置可选 []、命名可选 {}。一个签名里位置可选和命名可选不会共存,两种风格选一种,混写在编译期就报错。Flutter 的组件清一色用命名参数,所以我日常见到的几乎都是 {}:

void tag(String label, {required int index, bool highlight = false, String? color}) { ... }

tag('主站', index: 3);                 // required 让调用点必须显式给名字,漏了编译不过
tag('主站', index: 3, highlight: true); // 顺序无所谓,可读性靠名字

required 在空安全之前就存在,但它真正的价值是空安全之后才显现的。以前命名参数不写默认值会自动变成可空类型,调用方漏传一个参数照样编译通过,那个值在运行时就是 null;空安全把「没给值」变成类型层面的约束之后,required 才能把漏传直接拦在编译期。这一点在 Flutter 组件签名里很吃香:key 之外的必填项都能靠它前移。

函数本身是值,可以存变量、当参数传、当返回值。函数类型的写法是「返回类型在前」:

typedef TagBuilder = Widget Function(String label, {required int index});

Widget buildCard(BuildContext context, TagBuilder builder) =>
    Container(child: builder('主站', index: 0));

闭包捕获的是变量本身,不是当时的值快照。这里有个跟 JS 不一样的地方让我印象深刻:

final getters = <int Function()>[];
for (var i = 0; i < 3; i++) {
  getters.add(() => i);
}
print(getters.map((f) => f()).toList()); // [0, 1, 2]

Dart 的 for (var i = ...) 每一轮迭代都给 i 新建一个绑定,所以三个闭包各拿各的。同样结构在 JS 里用 var 会得到 [2, 2, 2](换成 let 才正常)。我因为 JS 的经验先入为主,专门写了这段跑一遍确认,结论是 Dart 不需要绕这一手。

另外 Dart 2.15 之后有 tear-off,items.where(isActive) 可以直接传函数名,不用写成 items.where((e) => isActive(e))。

类的组合:mixin、命名构造、factory

Dart 是单继承,多出来的复用能力靠 mixin。它和 Java 接口的 default method 不同:mixin 可以有实例字段,而且能限定自己能混进谁。

mixin TapCounter on State<MyWidget> {   // on 约束:只能混进 State<MyWidget> 的子类
  int _count = 0;
  void onTap() => setState(() => _count++);  // 因为有 on 约束,setState 直接可用、不用声明
}

class _MyWidgetState extends State<MyWidget> with TapCounter {
  @override
  Widget build(BuildContext context) => Text('点了 $_count 次',
      semanticsLabel: '计数');
}

on 约束给我的是类型安全感:mixin 里能直接调宿主类的方法,编译器保证这些方法一定在。少了 on,mixin 就只能靠 noSuchMethod 那种「指望宿主恰好有这个方法」的写法,Java 的 default method 就是后者。

多个 mixin 时顺序有语义,这一点我第一次读框架源码时被绕住过:

class _MyState extends State<MyWidget> with A, B { ... }

with 列表里越靠右的越外层,B 优先;B 里调 super.foo() 走的是 A,A 里再走 State。Flutter 内部大量靠这个语义来「插一层」而不覆盖上一层,我读 StatefulElement 那一段时以为顺序反了,把 didChangeDependencies 的调用链重画一遍才对上。

把普通 class 写进 with 是这个语法最常见的报错来源:

The class 'X' can't be used as a mixin because it's not a mixin class nor mixin.

成因是这个 class 有构造函数(或显式声明了非 mixin)。我的处理通常是把它改声明成 mixin class X(Dart 2.12 起允许),这样它既能被 extends 也能被 with。

命名构造函数(X.后缀名)我在 Flutter 里见得最多的是这几类:DateTime.now()、Duration(seconds: 1)、MaterialApp.router(...),还有每个组件签名里那一行 const Foo({super.key})。它解决的是「同一个类、不同入参语义」。

factory 构造函数我想单说一句,因为它名字有误导性:它不保证新建对象,可以返回缓存里的旧实例,而且返回的运行时类型不一定是被调用的那个类。

class Session {
  static Session? _shared;
  final String token;
  Session._internal(this.token);              // 私有的真正构造函数

  factory Session.shared() => _shared ??= Session._internal(_readToken());  // 复用同一个实例
}

我在 State 相关代码里对 factory 的直觉一直是「框架里那个 createElement()」——父类决定给你造哪种 Element,语义上确实是同一类「由被调用方挑实例」。但严格讲那是普通实例方法,Dart 语法层面 factory 对应的场景是单例、实例缓存、以及跨类型的复用,跟 State 没直接关系。这个混淆我花了几天才解开,写在这里省时间。

异步:Future / Stream / await,以及 await 之后的 context

三件套的关系,我用一句话概括自己的理解:Future 是「一个以后会有结果的东西」,async/await 是它的语法糖,Stream 是「以后会有一串结果」。

Future 只有一个值,then 之后结束;Stream 可以多次推送,需要订阅,也就有了「取消订阅」这个额外的心智负担。

// 顺序 await:耗时相加
final user = await api.fetchUser();        // 300ms
final orders = await api.fetchOrders();    // 400ms,等在上面之后才开始 → 合计约 700ms

// 并发发出:取最慢的那个
final results = await Future.wait([api.fetchUser(), api.fetchOrders()]);  // 约 400ms
final u = results[0] as User;
final list = results[1] as List<Order>;

Future.wait 里两个请求是同时发出的,因为它们创建时就开工了,await 只是等结果。这个顺序我一开始没意识到,写成 await 串行的版本,页面白屏时间直接翻倍,抓包才看出来是两次请求在排队。

它的失败语义我自己吃过一次:任一 Future 出错时,返回的 Future 以第一个错误结束,其余的失败不会浮出来。所以我后来在有并发请求的地方都只把它用在「全成功才有意义」的场景,否则改成先 catch 各自包一层。

await 之后 context 可能已经失效,是这一篇里我最想写的一条。场景很常见:进入页面就发请求,请求回来弹个提示。

class _ProfilePageState extends State<ProfilePage> {
  @override
  void initState() {
    super.initState();
    _load();
  }

  Future<void> _load() async {
    final user = await api.fetchUser();   // 让出控制权;这段时间用户可能已经返回上一页
    if (!mounted) return;                 // mounted 为 false 说明 State 已从树上摘下
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(user.name)));
    // 少了上面那行守卫,这里就是崩溃现场
  }
}

为什么会这样:await 让出控制权之后事件循环继续跑,帧照常渲染,用户照常点返回。用户返回时,这个 State 会被 deactivate、unmount,而它持有的 context 指向的 Element 已经从树上摘掉。等 _load 的续体在 microtask 里被唤醒,context 还在(Dart 不回收引用),但它背后的 Element 已经不是活跃节点了。

我遇到过的报错原文是这两条之一:

Looking up a deactivated widget's ancestor is unsafe.
setState() called after dispose(): _ProfilePageState#...

第一条是拿 context 找上层(Theme.of(context)、Navigator.of(context))时抛的,第二条是 setState 抛的。成因都是同一个:await 之后没检查 mounted。我排查时的入口是错误文本本身——栈里出现 deactivated 或 after dispose,问题基本就在最近的 async 回调里,看它有没有 mounted 守卫就够了。didChangeDependencies 里发请求、GlobalKey 跨 await 使用,都是同一个坑的变体。

Stream 侧我也有过一次教训。await for 读流的时候如果外层提前 return 或抛异常,订阅不一定取消;listen 返回的 StreamSubscription 如果不存下来,就没有对象可以在 dispose() 里 cancel。我第一次就是这么写的,页面反复进出之后同一个流被订阅了七八次,表现为一条消息进来界面刷新多次。

record 和模式匹配在解 JSON 时的用法

Dart 3 加了 record(定长元组,可以带具名字段)和模式匹配。我在解 JSON 这件事上很喜欢这两样组合,因为它们省掉的是「为一坨一次性数据建一堆 DTO 类」。

(String, int) parseCell(Map<String, dynamic> row) =>
    (row['name'] as String, row['age'] as int);

final (name, age) = parseCell(jsonDecode(body));   // 解构,不用为这一对值单独建类

具名字段的 record 更像轻 DTO:

({int id, String title}) toItem(Map<String, dynamic> m) => (
      id: m['id'] as int,
      title: m['title'] as String,
    );

对结构不固定的 JSON,模式匹配能把「探形状」和「取用」合进一个表达式:

Widget bodyFrom(dynamic json) => switch (json) {
      // when 守卫:status 为 ok 之外还要求 items 非空
      {'status': 'ok', 'items': final List items} when items.isNotEmpty =>
          ItemList(items.cast<Map<String, dynamic>>()),
      {'status': 'ok', 'items': _} => const EmptyTip(),
      {'status': 'error', 'message': final String m} => ErrorTip(m),
      _ => const UnknownShape(),
    };

配合 sealed class,编译器能检查分支是否穷尽:

sealed class ApiResponse<T> {}
class Ok<T> extends ApiResponse<T> { final T data; Ok(this.data); }
class Fail<T> extends ApiResponse<T> { final String message; Fail(this.message); }

String describe(ApiResponse<User> r) => switch (r) {
      Ok(data: final u) => '欢迎,${u.name}',
      Fail(message: final m) => '出错了:$m',
    };
// 少写一个分支 analyzer 直接报非穷尽;以后给 ApiResponse 加子类,
// 所有 switch 处都会被点出来——这是我喜欢的性质,不用靠全局搜索

解 JSON 最常见的运行时错误是 cast:

type 'String' is not a subtype of type 'int' in type cast

出现的位置基本都是 m['id'] as int。我在生产数据里碰上它的原因是后端把数字序列化成了字符串(或者那个字段这次是 null)。判断方法我改成先打印 m['id'].runtimeType 一眼看真实类型;确认后端不守约定之后,那边就用 (m['id'] as num?)?.toInt() ?? 0,比裸 as int 抗造。

学习时要特别注意的几处

  • 症状:LateInitializationError: Field 'xxx' has not been initialized.
    原因:late 字段在首次读取前没被赋过值,而这条检查推迟到运行时才发生,编译期看不到。
    怎么判断:错误文本里带了字段名,我从这个字段名倒推它本该在哪一步被赋值——我那一次是 initState() 里的赋值在重构时被删掉了。
  • 症状:Null check operator used on a null value
    原因:! 只是把可空类型在编译期当成非空,运行时值确实是 null。
    怎么判断:栈顶那一行的 ! 是唯一起点。我当时的做法是先临时换成 ?. 让表达式短路,确认「确实是 null」而不是别的类型问题,再往上追它为什么会是 null。
  • 症状:Looking up a deactivated widget's ancestor is unsafe. 或 setState() called after dispose()
    原因:await 期间页面被 pop,State 已卸载,续体仍然握着旧的 context。
    怎么判断:错误里出现 deactivated / after dispose 这两个词时,我基本直接跳到最近的 async 回调,检查它有没有 if (!mounted) return; 这一行——缺了就是它。
  • 症状:Unsupported operation: Cannot add to an unmodifiable list
    原因:那个容器是 const 字面量(const [] 作为默认参数值很常见),编译期常量集合不可变。
    怎么判断:抛错行本身往往看不出问题,得往上找这个 list 的来源。我找到过的是某个默认值写了 const [],下游直接 add;需要可变副本时我用 List.of(...) 拷一份出来。
  • 症状:The class 'X' can't be used as a mixin because it's not a mixin class nor mixin.
    原因:被混入的类带构造函数,或者根本没声明成 mixin/mixin class。
    怎么判断:报错里给了类名,直接看那个类的声明。我遇到的是一个有构造函数的类被写进了 with,最后把它改成 mixin class,另一处则本来就是 extends 的语义、写错了关键字。
  • 症状:with M 的地方报宿主类型不满足 M 的 on 约束
    原因:宿主类不是 on 里那个类型的子类。
    怎么判断:把宿主类的 extends 和 mixin 的 on 两行放一起看就能对上。这个约束本身是保护性的——报出来通常说明这条 mixin 挂错了地方,而不是约束写严了。
  • 症状:两个接口分别 300ms 和 400ms,页面白屏却接近 700ms
    原因:写成了逐个 await,两个请求排队而不是并发。
    怎么判断:在两个 await 前后打时间戳,或者直接抓包看发起时刻。并发时总耗时接近最慢的那个(400ms),串行时是两者之和(700ms)——我那次的数字落在后者。

评论

暂未收到

写下评论

支持 Markdown 语法