認識 Dart “Extension Types”,強化 Flutter 互通性開發

Yii Chen
Flutter Taipei
Published in
23 min readMay 28, 2024

Extension Types in Dart 3.3

官方在文件開頭就提到:

“An extension type is a compile-time abstraction that “wraps” an existing type with a different, static-only interface.”

Extension Types 是 compile time abstraction,像是一個強大的類型包裝器。它的出現,是為了效能最佳化以及強化與 Native 程式碼的互動。身為零成本包裝器,在與原生平台溝通時,可以去除典型 Wrapper Class、Helper Class 會有的記憶體成本。

一般的 Class Wrapper 是在 run-time 運行,始終都會存在 Class 和 Object 的使用開銷,因此增加了記憶體使用與 GC 的處理成本。而當使用情境會在短時間生成非常多 Wrapper 實體時,就是一個應用的龐大負擔。

而 Extension Types 在編譯就確認它是某個類型的延伸,所以在 run-time 使用它時,這個抽象類型會消失,並以原有的 Representation Type 表現。因此對於應用來說,Extension Type 的使用沒有成本,是個非常良好的開發方式。

Extension Types 是 statifc JS interop 的主要幫手,可以輕鬆與現有 JS 類型的介面互動,同時確保不會產生實際的包裝器成本。

優點

1. 彈性的約束或擴充

允許強化現有類型,例如:int、String,添加屬性、函數等 API。

2. 更清晰的抽象

隱藏底層 Representation Type 的複雜性,進行更有意義的擴充,從而提高程式碼的可讀性和可維護性。

3. 方便與安全的互通性

使用自定義的 Dart 類型就像直接存取底層類型一樣,同時提供了類型安全性。對於與原生平台和其他語言的互通性非常有用,簡化了過程。

4. 增強的效能

避免為每個服務或特定的底層溝通建立 Wrapper Class,不會產生額外的記憶體開銷。非常適合效能敏感的場景,特別是在處理大型資料集或頻繁的物件操作時。

開發

Extension Type 本身在聲明時,預設擁有主要建構函數。

extension type MyId(int id) {}

void main(List<String> arguments) {
final id = MyId(1);

print(id); // 1
print(id.runtimeType); // int
}

在任何情況下,Extension Type 包裹的類型稱為 Representation Type,而它不是子類型,因此在一般情況下 Representation Type 與自定義的 Extension Type 不能互相賦值。

當沒有自定義新的屬性、函數介面時,什麼功能操作都沒有。限縮了 int 的原有行為

對於既有的類型、型別進一步擴展或約束,只暴露可使用的 API,進而避免一些不允許的操作。可以幫它加上一些我們需要且有意義的功能

extension type MyId(int id) {
operator >(MyId other) => id > other.id;

bool isBiggerThan(MyId other) => id > other.id;
}

void main(List<String> arguments) {
MyId safeId = MyId(200);
safeId + 10; // Compile error: No '+' operator.
safeId - 10; // Compile error: No '-' operator.
safeId > 10; // Compile error: Wrong type.
safeId > MyId(300); // ✅

int number = 100;
number = safeId; // Compile error: Wrong type.
number = safeId as int; // ✅ Cast to representation type.
safeId = number as MyId; // ✅ Cast to extension type.

print(safeId.isBiggerThan(MyId(300))); // false
}

可讀性也會因此提升,假設使用 int 乘載某個服務或 native api 互動後回傳的值,可以將它透過 Extension Type 給予特定名稱。因此在閱讀時能更快的了解表達意義 。

Extension Type 和 Representation Type 兩者可以直接相互轉換,使用 as 轉型。特別的是,他們不處於繼承關係卻可以強制轉換。

i = id as int; // ✅
i = -1;
id = i as Id; // ✅

以下方範例來看,我們可以將擴充類型作為一般的 Dart 類,可以實例化並呼叫自訂函數,不同的地方相當於 Dart 將其編譯為普通 int

extension type Wrapper(int i) {
void showValue() {
print('my value is $i');
}
}

void main() {
final wrapper = Wrapper(42);
wrapper.showValue(); // Prints 'my value is 42'
}

官方說明這對於與 Native 的互通性特別有用,可以直接使用原生類型,無需建立包裝器並耗費間接的成本,同時提供乾淨的 Dart API。

泛型

extension type MyList<T>(List<T> elements) {
void add(T value) => elements.add(value);
}

void main(List<String> arguments) {
MyList list = MyList<int>([1, 2]);
list.add(3);

final normalList = list as List<int>;

print(list); // [1, 2, 3]
print(normalList); // [1, 2, 3]
}

建構函數

多種建構函數的使用:

  1. 一般建構
  2. 命名建構
  3. 隱藏建構,使用與類別相同的私有建構函數語法 _
extension type Password._(String value) {
Password(this.value) {
assert(value.length >= 8);

if (value.length < 8) {
throw Exception('Password must be at least 8 characters long');
}
}

Password.random() : value = _generateRandomPassword();

static String _generateRandomPassword() => ...;

bool get isValid => value.length >= 8;
}

void main(List<String> arguments) {
// Implicit unnamed constructor.
Password password = Password('abcdefghijklmnopqrstuvwxyz'); // ✅

// Named constructor.
password = Password.random(); // ✅
password = Password('hello12'); // Exception: Password must be at least 8 characters long
password = 'hello' as Password; // ✅
}

記得,不能在隱性的主要建構函數使用 assert() 檢查或是其他操作,可以在裡面覆寫主要建構,在這時添加 assert 處理。這時候記得將原有的主要建構改為私有的命名函數

extension type Password._(String value) {

Password(this.value) {
assert(value.length >= 8);

if (value.length < 8) {
throw Exception('Password must be at least 8 characters long');
}
}

}

安全別名

使用 implements 允許 Extension Type 暴露底層類型,可以呼叫 Representation type 的所有成員,以及自定義的任何輔助 API。具有原本型別的能力,同時擁有別名和可受檢查的安全性。

extension type Height(double _) implements double {}
extension type Weight(double _) implements double {}

double calculateBmi(Height height, Weight weight) => weight / ( height * height);

void main() {
var height = Height(1.75);
var weight = Weight(65);
var bmi = calculateBmi(height, weight);
print(bmi); // 21.22448979591837

bmi = calculateBmi(1.64, 54.0); // ❌ compile-time error
bmi = calculateBmi(weight, height); // ❌ compile-time error
}

當拿它作為函數的參數時,不同別名雖然擁有共同能力,但是無法相容使用。這點就與 typedef 的別名寫法有所區別,typedef 無法受益於編譯器的類型檢查。

針對原有的 type 添加新的介面:

extension type MyId(int id) implements int {
MyId get value => this;
}

void main(List<String> arguments) {
final safeId = MyId(100);
safeId + 1; // 101
safeId - 1; // 99
safeId * 2; // 200
safeId / 2; // 50
safeId % 3; // 1
safeId.toString(); // '100'

int normalId = safeId; // 100
final safeId2 = safeId + normalId; // 200
final safeId3 = 10 + safeId; // 110
}

重新定義原有的功能行為,Extension Type 的成員完全取代同名的父類型成員,提供新的實現方式。

extension type MyId(int id) implements int {
bool get isEven => true;
}

void main(List<String> arguments) {
final myId = MyId(101);

print(myId.isEven); // true
}

可以使用 @redeclare 重新定義原有的功能行為,Extension Type 的成員完全取代同名的父類型成員,提供新的實現方式。

extension type MyId(int id) implements int {

@redeclare
String toString() => 'New version: ${toString()}';

}

其他情境

多類型的擴展

通常 Extension Types 都是擴展一個 type,當有多種資訊時可以搭配 Record 使用。

typedef UserInfo = ({String email, String password});

extension type User(UserInfo info) {

void printInfo() => print("Email: ${info.email}, Password: ${info.password}");

}

void main(List<String> arguments) {
final user = User(
(
email: 'extension@gmail.com',
password: 'types',
),
);
user.printInfo(); // Email: extension@gmail.com, Password: types
}

模擬測試數據

針對於測試環節,使用 Extension Type 也是沒問題,根據 Mock Class 我們只需微調寫法即可。差異是,一般的類別如果有沒有實作的介面,會出現編譯提醒,而 Extension Type 不會有。但好處是,針對多測試的情境下,使用 Extension Type 還是能以最省資源的方式去運作。

abstract final class Repository {
String getToken();
}

final class MyRepository implements Repository {
@override
String getToken() {
return 'hello world';
}
}

extension type MockRepository(Repository repository) implements Repository {
String getToken() {
return 'Testing';
}
}

void main(List<String> arguments) {
final myRepository = MyRepository();
final mockRepository = MockRepository(myRepository);

print(mockRepository.getToken()); // Testing
}

JSON 存取

不需要一般的 Data Class,除了節省記憶體之外,速度快。

final userMap = json.decode(r'''
{
"name": {
"first": "Yii",
"last": "Chen"
},
"email": "ab20803@gmail.com"
}
'''); // Map<String, dynamic>

extension type User(Map<String, dynamic> _) {
Name get name => _['name'] as Name;
String get email => _['email'] as String;
}

extension type Name(Map<String, dynamic> _) {
String get first => _['first'] as String;
String get last => _['last'] as String;
}

void main() {
final person = User(userMap);
print(person.name.first); // Yii
print(person.name.last); // Chen
print(person.email); // ab20803@gmail.com
print(person.email.length); // 17
}

Interoperability 互通性

目前 Extension Types 是 dart:js_interop 套件主要的開發方式,允許存取 JavaScript API 並使用慣用語法進行互動。其中定義了許多 JS 相關類型,例如:JSObject、JSAny,實現 Dart 與原生兩端的安全溝通。當然 C++ 等其他語言的開發也都能受益。

external → 關鍵字讓我們能存取外部功能、外部函數,常見的是來自另一種語言,所以在 Dart 互通性開發上都會看到。

@JS() → 如果 Dart 端想以不同名稱實現,或想要編寫多個 Dart API 指向相同的 JS API,可以定義互通的 JS API 名稱。

套件示範

flutter_soloud 是音頻播放套件,底層由 C++ 開發,提供高效、性能好的播放器。從原始碼摸索,當我們要執行播放功能時,由 player.cpp 裡的 play 函數開始,最後在 Dart 端透過 soloud.play() 執行,並取得音頻任務的 ID。

此 ID handle 由自定義的 SoundHandle 包裝,確保一定的可讀性與性能。

final soloud = SoLoud.instance;
await soloud.init();
final source = await soloud.loadAsset('path/to/asset.mp3');

SoundHandle soundHandle = await soloud.play(source); // id(int)

await soloud.stop(soundHandle);
await soloud.disposeSource(soundHandle);

以下快速看整個 API 運作的流程:

  1. 使用 player.cppplay() 取得 audio handle
https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/src/player.cpp#L267

2. 使用 bindings_player_ffi.dart 處理兩端的溝通,將拿到的 handle id 透過 SoundHandle 包裝

https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/lib/src/bindings_player_ffi.dart#L396C25-L396C36

3. 在 Flutter 端,使用 soloud.dart 呼叫 play(),會得到一個結果是 Record type,將它的newHandle 拿出來,也就是我們要的 ID

https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/lib/src/soloud.dart#L844

4. ID 在 Dart 端使用 SoundHandle 進行包裝,是個有意義的 Extension Type。後續使用它進行其他的 Audio 控制

https://github.com/alnitak/flutter_soloud/blob/bd77bb113fa9697b7d33ac10f75988ed72e312e6/lib/src/sound_handle.dart

進階用法

工廠建構:Representation Type 上有另一種擴充類型。這允許跨多個擴充類型重複使用操作(類似於多重繼承)。

extension type Number(int i) {
const factory Number.zero() = Number2;
}

extension type Number2(int i) implements Number {
const Number2(int value) : this(i: value);
}

注意

Extension Type 是 compile-time 的包裝行為。在 run-time 時,沒有它的存在。此時任何類型的查詢或操作都適用於 Representation type。這也使得 Extension Type 成為不安全的抽象,因為始終可以在 run-time 時找到原本的表示類型並存取底層物件。

extension type Id(int value) {}

void idToInt() {
var id = Id(1);

// Run-time type of 'id' is representation type 'int'.
if (id is int) print(id.value); // 1

// Can use 'int' methods on 'id' at run time.
if (id case int x) print(x.toString()); // 1

switch (id) {
case int(:final isEven):
print("$id (${isEven ? "even" : "odd"})"); // 1 (odd)
}
}

void intToId() {
int i = 2;

if (i is Id) print("It is"); // It is

if (i case Id id) print("value: ${id.value}"); // value: 2

switch (i) {
case Id(:var value):
print("value: $value"); // value: 2
}
}

總結

  1. Extension Types 可以約束現有類型,限制原有的能力
  2. Extension Types 可以為現有類型添加新的介面,擁有新的能力
  3. 了解 Extension Types 的性質很重要,它只存在於 compile-time,在 run-time 會被忽略,並以 Representation Type 表現
  4. 雖然真正的 Class Wrapper 更安全。但權衡之下,Extension Type 可以在某些情況下節省成本,極大地提高性能,尤其是互通性開發的改善

Extensions 比較

  • Extension Methods → 適合為現有類型添加簡單功能
  • Extension Types → 強化現有類型,實現複雜場景和操作,優化與其他程式語言的互通性

Reference

Other Articles

--

--

Yii Chen
Flutter Taipei

Flutter Lover || Organizer FlutterTaipei || Writer, Speaker || wanna make Flutter strong in Taiwan. https://linktr.ee/yiichenhi