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:htmlpackage:js 这些 legacy web 库在 dart2wasm 下不支持,Dart 3.7 就标记废弃了。如果还在直接用这些库,该迁到 dart:js_interoppackage: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 小时前
Ubuntu 安装 Node.js 22.x(通过 NodeSource 官方源)
前端
Highcharts1 小时前
如何选择正确的图表:驱动理解和洞察力的三部分框架(第3部分)
前端·数据可视化
JavaGuide1 小时前
Github 史诗级故障,与此同时,Cursor 版「GitHub」正式上线!
前端·后端
郭邯1 小时前
用 AI 写了一个经纬度格式转换工具,从需求到落地的完整过程
前端
不一样的少年_1 小时前
修了 Bug、做了重构,为什么老板还是觉得你没产出?
前端·后端·程序员
马可家的菠萝1 小时前
Vue3 + Canvas 手绘笔记工程化实践:别把画布只当成一张 PNG
前端·vue.js·算法
IMPYLH1 小时前
HTML 的 <h1>–<h6> 元素
前端·javascript·html
IMPYLH1 小时前
HTML 的 <head> 元素
前端·html
卸任2 小时前
AI英语学习助手:从翻译工具到 AI 英语学习助手
前端·electron