跪拜 Guibai
← Back to the summary

Dart 3.13 Shrinks Native Binaries and Preps Dynamic Module Loading

Primary Constructors

A notable change is that Primary Constructors have officially graduated to stable. We’ve discussed this twice before. For example, previously, writing a simple data class often required repeating the same information twice:

class Point {
  final int x;
  final int y;

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

In 3.13, you can write it directly as:

class Point(final int x, final int y);

Here, final int x defines both the constructor parameter and the instance field. var declares a mutable field, while writing just int x makes it only a constructor parameter without automatically becoming a field.

However, AI currently isn't very accustomed to writing this way. If you don't enforce it, AI will still use the old syntax. To address this, the team released a batch of new lints:

3.13 even added concise syntax for traditional constructors. Inside a class, you can use new instead of repeating the class name:

////Before
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);
  }
}

////Now
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);
  }
}

Roughly, the mapping is:

Point(...)              → new(...)
Point.origin(...)       → new origin(...)
factory Point.clone(...)→ factory clone(...)

The team released multiple lints and IDE refactors, including automatically converting old constructors to primary constructors, aiming to get you to switch to the new syntax as much as possible.

Native Tree Shaking

Another feature is Native Tree Shaking. Previously, Flutter/Dart's tree shaking essentially stopped at the FFI boundary. For example, if a Flutter package bundles an entire SQLite, encryption library, image decoder, or Rust library via FFI, Dart AOT could know you only used a few Dart wrappers and remove the unused Dart code. However, the native library actually packaged into the APK, IPA, or desktop program might still be a large, complete binary.

Because the Dart compiler knows "whether these Dart FFI functions are actually called," but the native linker doesn't know what happened on the Dart side.

3.13 introduces @RecordUse, package:record_use, and link hooks specifically to bridge this information gap.

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);

During AOT compilation, the compiler records which marked FFI bindings are called by the final reachable code. It then passes this information to the package's hook/link.dart via LinkInput.recordedUses. The link hook maps the Dart bindings to the actual C/Rust symbols, finally letting the native linker keep only those symbols.

For instance, if a native library exposes 300 APIs but your app only uses 20, theoretically the remaining unreferenced native code can also be stripped. If a native library has no functions used at all, the entire library can be excluded from the final bundle. ffigen will also cooperate to generate the corresponding information.

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 {
    // Extract symbols for functions called in reachable Dart code:
    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,
      ),
    );
  });
}

This is quite important for Flutter's future push on Code Assets. Code Assets has been solving the problem of "how Dart Packages naturally carry, compile, and bind C/C++/Rust code." Now Dart is starting to solve the other half of the problem:

How these native assets ultimately participate in Dart's whole-program optimization.

Previously, Dart compilation and native compilation were like two independent pipelines. Now, the build hook handles compiling native code, Dart AOT calculates code reachability, and the link hook processes the native library using the usage information provided by the Dart compiler.

If this continues to develop, the cost of Flutter Packages with Rust/C++ dependencies will be much lower than it is now. Package authors can expose a full set of native APIs, and the App only pays the size cost for the parts it actually uses.

Web

The main change for Web is dart2wasm's deferred loading, which we also mentioned in the Flutter update. The biggest problem for large Flutter Web Apps is the initial load. 3.13 now allows splitting deferred Dart code into independent Wasm modules via --enable-deferred-loading, loading them when needed.

However, this is still in an early experimental stage, and the embedder needs to provide its own callback for loading Wasm module bytes, so it's still some distance from being completely seamless.

Runtime

There are also some changes in the Runtime this time, for example:

Surprising, isn't it? Although it's still far from being Flutter Code Push, this is indeed interesting, because Dart AOT has always heavily relied on the closed-world assumption, defaulting to the entire program being determined at compile time, which is a key reason it can perform aggressive Tree Shaking and optimization.

Dynamic Modules currently aims to solve development workflows in mobile environments without JIT, specifically the issue of JIT on iOS 26. The current approach is still somewhat hacky, but the team also stated that there is currently no priority for server-driven UI in production. Of course, the future is hard to say.

Pub

dartdoc can now use {@example} to directly reference real code snippets from the example/ directory, and can use #hide to hide boilerplate code necessary for the example to actually run, for example:

// example/foo.dart
void main() {
  // #region abc
  // Included in documentation
  foo();
  assert(false); // #hide
  // #endregion
}

Then reference that region in your Dart documentation comments:

/// This is a great function.
///
/// Example usage:
/// {@example /example/foo.dart#abc}
void foo() {}

This design is quite practical because previously, examples in API documentation were often copied code. Over time, documentation examples and the actual examples could easily drift apart. Now they can share the same source file:

Moreover, pub.dev has switched to a new two-level Hash Index to handle dartdoc file lookups. The team specifically mentioned that for large packages with hundreds of thousands of generated files, documentation rendering latency can be significantly reduced:

a5d35a5b-5e0c-45db-817f-fa963611b679

Tool

dart format also has several visible adjustments this time, mainly in method chains and imports, such as:

A fix for a previous method call formatting error, where the optimizer sometimes incorrectly triggered and caused malformed code:

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

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

Previously, some function(argument).method().another() chains would break the preceding function call into pieces when wrapping lines. Now it prefers to keep the simple target intact and expand the call chain line by line.

Additionally, the heuristic algorithm for deciding whether to split a method call chain "at the dot" or "inside the argument list" has been changed. If the target of a method chain is a collection literal or a function call with a single element or argument, it now prefers splitting the chain rather than the target:

// Before, split the target:
function(
  argument,
).method().another();

// After, split the chain:
function(argument)
    .method()
    .another();

Blank lines are also automatically inserted between dart:, package:, and project-local imports:

// Before:
import 'dart:io';
import 'dart:math';
import 'package:args/args.dart';
import 'package:test/test.dart';
import 'my_library.dart';

// After:
import 'dart:io';
import 'dart:math';

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

import 'my_library.dart';

Finally

So, what do you think? Doesn't this minor Dart version update feel more exciting than Flutter? I feel Dart 3.13 really suits my preferences, especially the more flexible Tree Shaking. The brand new Dynamic Module is also worth looking forward to; at least we might not have to endure the delayed hotload of JIT anymore.