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 parameterunnecessary_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 或更早版本,建议找个时间升上去试试。
相关链接
- 官方博客:dart.dev/blog/announcing-dart-3-13
- SDK Changelog:github.com/dart-lang/sdk/blob/main/CHANGELOG.md
- Primary Constructors 文档:dart.dev/language/primary-constructors
- Native link hooks 文档:dart.dev/tools/hooks#link-hooks
- Dynamic Modules 说明:github.com/dart-lang/sdk/pkg/dynamic_modules