Dart 3.13:小版本,大动作

Primary Constructors 转正、Native Tree Shaking、Dynamic Modules 实验性登场

Flutter 3.47刚发的时候,我特意去看了一眼Dart 3.13的进度,但没看到什么太多东西,只有一个Primary Constructors 转正,也没怎么上心,毕竟之前也写过Primary Constructors的文章。但当Dart 3.13真正发布以后,我发现里面的东西还不少呢:

Primary Constructors 正式转正,Native Tree Shaking 打通了 FFI 体积优化的最后一公里,WebAssembly deferred loading 进入预览,Runtime 引入了 memory cage 和 Dynamic Modules 实验。一口气塞了这么多东西进来,说实话比最近几版 Flutter 本身的更新还带感。

这次的主题用官方的话说叫 "clean and lightweight code" ------ 简洁和轻量。从语言语法到编译产物,从工具链到 pub.dev 文档,每一层都在往这个方向走。我挑几个值得聊的点展开说说。

Primary Constructors 正式转正

这个特性我还是很喜欢的,虽然我现在基本只有AI写代码了。

Primary Constructors在 Dart 3.12 还是实验性预览,3.13 正式 stable。之前写过文章聊过这个特性,简单地说就是:定义一个数据类时,字段名和类型在声明和构造函数里要重复写两遍。

以前的写法:

dart 复制代码
class Point {
  final int x;
  final int y;

  Point(this.x, this.y);
}

现在一行搞定:

dart 复制代码
class Point(final int x, final int y);

final int x 同时定义了构造参数和实例字段。如果用 var 就是可变字段,如果只写 int x 不加修饰符,那它只是构造参数,不会自动变成字段。

3.13 还给传统 constructor 加了 concise syntax,类内部可以用 new 代替重复写类名:

dart 复制代码
// 以前
class Point {
  double x;
  double y;

  Point(this.x, this.y);

  Point.origin() : x = 0, y = 0;

  factory Point.clone(Point other) {
    return Point(other.x, other.y);
  }
}

// 现在
class Point {
  double x;
  double y;

  new(this.x, this.y);

  new origin() : x = 0, y = 0;

  factory clone(Point other) {
    return Point(other.x, other.y);
  }
}

对应关系很直观:Point(...) 变成 new(...),Point.origin() 变成 new origin(),factory Point.clone() 变成 factory clone()。

既然是新的东东,那就有个很现的实问题:AI目前还不太习惯主动用这套新写法,你不强制要求的话它还是会给你生成老风格。官方显然也意识到了,一口气配套了 6 个 lint 规则加自动修复:

  • empty_container_bodies ------ 空类体用 ; 代替 {}
  • use_declaring_parameters ------ 鼓励用 declaring parameter
  • unnecessary_type_name_in_constructor ------ 用 new 替换构造函数里的类型名
  • initialize_in_field_declaration ------ 初始化移到字段声明处
  • unnecessary_primary_constructor_body ------ 移除多余的构造函数体
  • unnecessary_const_in_enum_constructor ------ 移除 enum 构造函数里的 const

IDE 侧也加了四个自动重构操作:转成 primary constructor、转回 in-body constructor、转成 declaring parameter、把初始化移到字段声明。这套组合拳打下来,迁移成本基本趋近于零。

Native Tree Shaking:FFI 体积优化的最后一公里

这是我觉得 3.13 里最有工程含量的一块。先说背景:Flutter/Dart 一直有 tree shaking,能裁掉没用到的 Dart 代码。但到了 FFI 边界,这个能力就断了。比如一个 Flutter 包通过 FFI 带了 SQLite、加密库、图片解码器或者某个 Rust library,Dart AOT 编译器知道你只用了其中几个 Dart wrapper 函数,没用到的 Dart 代码可以裁掉,但底下打进 APK、IPA 里的 native library 还是完整的一坨二进制。

原因很简单:Dart 编译器知道哪些 FFI 函数被调用了,但 native linker 不知道 Dart 那边发生了什么。两个编译流水线各干各的,中间缺了一条信息通道。

3.13 通过 @RecordUse 注解(在 package:meta 里)和 package:record_use 把这条链打通了。整个流程分三步:

第一步:标记 FFI binding

dart 复制代码
import 'dart:ffi';
import 'package:meta/meta.dart';

@RecordUse()
@Native<Int32 Function(Int32, Int32)>()
external int sqlite3_open(
  Pointer<Utf8> filename,
  Pointer<Pointer<sqlite3>> ppDb,
);

@RecordUse()
@Native<Int32 Function(Pointer<sqlite3>)>()
external int sqlite3_close(Pointer<sqlite3> db);

第二步:AOT 编译时追踪可达调用

Dart AOT 做 whole-program 编译和 tree shaking 时,会记录哪些标记了 @RecordUse() 的 binding 在可达代码里被调用了。那些住在被裁掉的 Dart 代码里的调用不会被计入。

第三步:link hook 里裁剪 native 符号

dart 复制代码
import 'package:hooks/hooks.dart';
import 'package:native_toolchain_c/native_toolchain_c.dart';
import 'package:record_use/record_use.dart';
import 'package:my_package/src/c_library.dart';
import 'package:my_package/src/record_use_mapping.dart';

void main(List<String> arguments) async {
  await link(arguments, (input, output) async {
    // 提取可达代码中实际调用的符号
    final symbolsToKeep = input.recordedUses?.calls.keys
        .cast<Method>()
        .map((method) => recordUseMapping[method.name]!);

    await cLibrary.link(
      input: input,
      output: output,
      linkerOptions: LinkerOptions.treeshake(
        symbolsToKeep: symbolsToKeep,
      ),
    );
  });
}

link hook 拿到 Dart 编译器给出的使用记录,通过 package:record_use 查询哪些调用是可达的,然后告诉 native toolchain 只保留这些符号。

效果很直接:一个 native library 暴露了 300 个 API,你的 App 只用了 20 个,剩下 280 个对应的 native 代码可以被 linker 裁掉。如果一个 native library 一个函数都没被调用,整个 library 直接不进最终 bundle。ffigen 也会配合生成相应的标注信息。

这个特性对 Flutter 后续推 Code Assets 意味着什么?Code Assets 一直在解决「Dart Package 怎么自然地携带、编译和绑定 C/C++/Rust 代码」的问题,而 Native Tree Shaking 解决的是另外一半:这些 native assets 最终怎么参与 Dart 的 whole-program optimization。以前 Dart 编译和 native 编译是两条独立流水线,现在 build hook 负编 native code,Dart AOT 负算可达性,link hook 拿着 Dart 编译器的使用信息处理 native library。如果这条路走通了,Flutter Package 带 Rust/C++ 依赖的体积成本会大幅下降。

WebAssembly Deferred Loading 预览

大型 Flutter Web App 最大的痛点是首次加载 ------ 整个 Wasm 模块一次性拉下来,不管用户当前页面用不用得到那么多功能。3.13 开始支持 deferred loading,可以把延迟加载的 Dart 代码拆成独立的 Wasm module,需要的时候再加载。

启用方式:

bash 复制代码
dart compile wasm -O2 --enable-deferred-loading

官方说在大型应用里,dart2wasm 的 deferred loading 相比 dart2js 在首次页面加载(IPL)时间上有显著改善。不过目前还是早期实验阶段,embedder 需要自己提供加载 Wasm module bytes 的 callback,离完全无感还有距离。

另外提一句,dart:html 和 package:js 这些 legacy web 库在 dart2wasm 下不支持,Dart 3.7 就标记废弃了。如果还在直接用这些库,该迁到 dart:js_interop 和 package:web 了。

Runtime:Memory Cage 和 Dynamic Modules

Runtime 层有两个值得关注的动向。

第一个是 Dart heap 外面加了 memory cage,用来加强 native runtime 的内存安全。具体实现细节官方没有展开,但方向是明确的:让 native 侧的内存访问更可控,减少越界和泄漏的风险。

第二个更有意思 ------ Dynamic Modules 实验。Dart 团队开始研究运行期间动态链接 Dart 代码,官方明确提到这种能力可以用于团队内部快速共享 prototype 的开发工作流。

这件事的背景是:Dart AOT 一直依赖 closed-world assumption,编译时默认整个程序已经确定,这也是它能做激进 tree shaking 和全局优化的前提。但这个假设也意味着 AOT 模式下没法像 JIT 那样动态加载代码。iOS 上没有 JIT,hot reload 体验一直受限。Dynamic Modules 就是在尝试解决这个问题 ------ 虽然目前的做法官方自己也说还比较 hack,但方向已经亮出来了。

官方也说了,目前没有优先做 production 环境的 server-driven UI。但未来不好说。如果这条路走通了,Flutter 的 Code Push 能力就有了一个新的技术基础,不用再忍受 JIT 那种延迟 hotload 的体验。

另外还有几个底层工程进展:修了嵌套函数类型提升的一个罕见 unsoundness bug、重构了 common front-end 以便和 Dart analyzer 共享更多代码、DDC 继续移除 legacy module system 推进统一模块系统。

dart format 三处可见的调整

格式化器这次有几个肉眼可见的变化。

方法调用格式化 bug 修复

之前一个优化逻辑会误触发导致代码格式错误,主要出现在方法调用包含大集合字面量的场景。修复后长这样:

dart 复制代码
// 修复前
await MethodChannelContainer()
    .onMethodChannelInvoke('reportCrash', <String, Object?>{
      'time': nowTime,
      'errorValue': errorName,
      'reason': reason,
      'stacktrace': stacktrace,
    });

// 修复后
await MethodChannelContainer().onMethodChannelInvoke(
  'reportCrash',
  <String, Object?>{
    'time': nowTime,
    'errorValue': errorName,
    'reason': reason,
    'stacktrace': stacktrace,
  },
);

方法链拆分启发式调整

如果方法链的 target 是集合字面量或单参数函数调用,现在优先拆分调用链而不是 target:

dart 复制代码
// 以前,拆 target
function(
  argument,
).method()
.another();

// 现在,拆调用链
function(argument)
    .method()
    .another();

Import 分段

格式化器现在会在 dart:、package: 和项目自己的 import 之间自动加空行,符合 Effective Dart 的排序规则:

dart 复制代码
// 以前
import 'dart:io';
import 'dart:math';
import 'package:args/args.dart';
import 'package:test/test.dart';
import 'my_library.dart';

// 现在
import 'dart:io';
import 'dart:math';

import 'package:args/args.dart';
import 'package:test/test.dart';

import 'my_library.dart';

新的Formatter更倾向于保持方法链结构清晰

这些样式改动是 language-versioned 的,只有你把代码升级到 Dart 3.13 之后才会看到变化。官方也知道格式化改动会引起 churn,所以尽量保守,但在 AI 生成代码越来越多的当下,这点代价还是有必要的,毕竟代码的可读性还。

pub.dev:文档体验升级

pub.dev 这次加了两个实用的文档特性。

callout-box 标签

可以在 API 文档里插入带蓝色边框的提示框,用来标注注意事项或额外信息,比纯文本更醒目。

{@example} 指令

可以直接引用 example/ 目录里的真实代码片段,用 #region 标记区域,用 #hide 隐藏为了让示例能跑起来而存在的样板代码:

dart 复制代码
// example/foo.dart
void main() {
  // #region abc
  // 这段会出现在文档里
  foo();
  assert(false); // #hide  这行不会出现在文档里
  // #endregion
}

然后在文档注释里引用:

dart 复制代码
/// 这是一个好函数。
///
/// 示例用法:
/// {@example /example/foo.dart#abc}
void foo() {}

这个设计解决了 API 文档示例和真实 example 代码长期漂移的问题。以前文档里的示例往往是复制进去的一份代码,时间久了两边就不同步了。现在共用同一个源文件,改一处就行。

另外,pub.dev 部署了两级 hash index 来加速 dartdoc 文件查找。对于拥有 10 万级生成文件的大型 package,文档渲染延迟能明显下降。官方还放了一张延迟对比图,效果挺直观的。

我的看法

Dart 3.13 的更新密度确实超出了一个小版本号的预期。Primary Constructors 让数据类定义从四行变一行,配合 lint 和 IDE 重构工具的迁移体验做得很到位。Native Tree Shaking 是最实在的一块 ------ 以前 FFI 包的体积问题是很多团队犹豫要不要引入 native 依赖的原因,现在至少有了自动裁剪的机制。Dynamic Modules 虽然还很早期,但它暗示的方向 ------ 在 AOT 环境里实现动态代码链接 ------ 如果走通了,对 Flutter 开发体验的影响可能比这次所有其他特性加起来还大。

当然也有需要冷静的地方。Deferred loading 和 Dynamic Modules 都还在实验阶段,生产环境别急着上。Primary Constructors 虽然 stable 了,但 AI 工具和社区代码风格跟进还需要时间。格式化改动会引入 diff noise,团队升级时要有心理准备。

总体来说,这版的 Dart 更新比 Flutter 本身最近几版更有看头。语言在变得更简洁,编译产物在变得更小,工具链在变得更智能。如果还在用 Dart 3.12 或更早版本,建议找个时间升上去试试。


相关链接

相关推荐
拆房老料1 分钟前
【第四部分:Agentic 智能体设计模式】16. 一个 Prompt 搞不定复杂任务?试试 Prompt Chaining
java·前端·人工智能·设计模式·prompt
曹牧5 小时前
C#:空合并运算符
java·前端·数据库
小羊没烦恼!6 小时前
铁道部新客票系统设计(二)
开发语言·前端·数据库·python·oracle·c#·php
智能直播9 小时前
RK3518:1.5G 内存 + 8G 存储,如何跑起硬件编解码、Web、AI 识别、绿幕特效
前端·人工智能·5g
掘金酱10 小时前
【10.24 特别活动】AI 时代,程序员的新饭碗在哪里?
前端·人工智能·后端
excel10 小时前
多进程环境下使用 PM2 部署 Socket.IO:需要注意什么
前端
IT_陈寒10 小时前
Redis的原子性竟然失效了?记一次分布式锁翻车实录
前端·人工智能·后端
时间的情敌12 小时前
《回顾 2018 前端:Vue2 成熟期,Webpack4 发布,PWA 大火》
前端
Java后端的Ai之路12 小时前
前端技术Vue-09-webpack-vuejs-实战
前端·vue.js·webpack
LMT流氓兔12 小时前
第五单元——第4课:给工具参数加校验
java·前端·算法·ai·langchain