0%

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

使用 Flutter 從零到一

2016 年夏末,我在丹麥奧胡斯 Google 辦公室的新工作是使用 FlutterDart 在 Android/iOS 應用程式中實作動畫圖表。除了是「Noogler」之外,Flutter、Dart 和動畫對我來說都是全新的。事實上,我以前從未做過行動應用程式。我的第一支智慧型手機才用了幾個月——因為擔心用我的舊 Nokia 接聽電話面試會失敗而驚慌失措地買的……

我確實有一些來自桌面 Java 的圖表經驗,但那不是動畫的。我覺得……很奇怪。一部分像恐龍,一部分像重生。

簡而言之: 透過為 Android/iOS 應用程式編寫 Dart 圖表動畫,發現 Flutter 的 Widget 和 tween 概念的優勢。

轉移到新的開發堆疊會讓您意識到自己的優先事項。在我的清單中,最重要的是以下三項:

  • 強大的概念 透過提供簡單、相關的方式來組織想法、邏輯或資料,從而有效地處理複雜性。
  • 清晰的程式碼 讓我們能夠清晰地表達這些概念,而不會被語言陷阱、過多的樣板或輔助細節所分心。
  • 快速迭代 是實驗和學習的關鍵——軟體開發團隊以學習為生:真正的需求是什麼,以及如何用程式碼表達的概念來最好地實現這些需求。

Flutter 是一個新的平台,可以使用 Dart 從單一程式碼庫開發 Android 和 iOS 應用程式。由於我們的需求是相當複雜的 UI,包括動畫圖表,因此只建構一次的想法似乎非常有吸引力。我的任務包括使用 Flutter 的 CLI 工具、一些預先建構的 Widget 和它的 2D 渲染引擎——除了編寫大量的純 Dart 程式碼來建模和製作圖表動畫之外。我將在下面分享我學習經驗中的一些概念重點,並為您自己評估 Flutter/Dart 堆疊提供一個起點。

開發過程中從 iOS 模擬器擷取的簡單動畫長條圖

這是 Flutter 及其「Widget」和「tween」概念的 兩部分 介紹的第一部分。我將透過使用它們來顯示和製作如上所示的圖表動畫來說明這些概念的優勢。完整的程式碼範例應該可以讓您了解使用 Dart 可以達到的程式碼清晰度。而且我將包含足夠的細節,以便您可以在自己的筆記型電腦(以及模擬器或設備)上跟進,並體驗 Flutter 開發週期的長度。

起點是全新 安裝 Flutter。執行

1
$ flutter doctor

以檢查設定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ flutter doctor
[✓] Flutter (on Mac OS, channel master)
• Flutter at /Users/mravn/flutter
• Framework revision 64bae978f1 (7 hours ago), 2017-02-18 21:00:27
• Engine revision ab09530927
• Tools Dart version 1.23.0-dev.0.0
[✓] Android toolchain - develop for Android devices
(Android SDK 24.0.2)
• Android SDK at /Users/mravn/Library/Android/sdk
• Platform android-25, build-tools 24.0.2
• Java(TM) SE Runtime Environment (build 1.8.0_101-b13)
[✓] iOS toolchain - develop for iOS devices (Xcode 8.2.1)
• Xcode at /Applications/Xcode.app/Contents/Developer
• Xcode 8.2.1, Build version 8C1002
• ios-deploy 1.9.1
[✓] IntelliJ IDEA Community Edition (version 2016.3.4)
• Dart plugin version 163.13137
• Flutter plugin version 0.1.10
[✓] Connected devices
• iPhone SE • 664A33B0-A060-4839-A933-7589EF46809B • ios •
iOS 10.2 (simulator)

如果有足夠的勾號,您就可以建立 Flutter 應用程式。讓我們將其稱為 charts

1
$ flutter create charts

這應該會產生一個同名目錄:

1
2
3
4
5
charts
android
ios
lib
main.dart

已產生大約五十個檔案,組成一個完整的範例應用程式,可以安裝在 Android 和 iOS 上。我們將在 main.dart 和同級檔案中完成所有程式碼編寫,無需修改任何其他檔案或目錄。

您應該驗證是否可以啟動範例應用程式。啟動模擬器或連接設備,然後在 charts 目錄中執行

1
$ flutter run

然後,您應該在模擬器或設備上看到一個簡單的計數應用程式。它使用了 Material Design Widget,這很好,但它是可選的。作為 Flutter 架構的最頂層,這些 Widget 是完全可以替換的。

首先,讓我們用下面的程式碼替換 main.dart 的內容,這是一個簡單的起點,用於玩圖表動畫。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import 'package:flutter/material.dart';
import 'dart:math';

void main() => runApp(new ChartsApp());

class ChartsApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Charts',
home: new ChartPage(),
);
}
}

class ChartPage extends StatefulWidget {
@override
ChartPageState createState() => new ChartPageState();
}

class ChartPageState extends State<ChartPage> {
int dataSet = null;

void _refreshData() {
setState(() {
dataSet = new Random().nextInt(101);
});
}

@override
Widget build(BuildContext context) {
return new Scaffold(
body: new Center(
child: new Text('Data set: $dataSet'),
),
floatingActionButton: new FloatingActionButton(
onPressed: _refreshData,
tooltip: 'Refresh',
child: new Icon(Icons.refresh),
),
);
}
}

儲存變更,然後重新啟動應用程式。您可以從終端機執行此操作,方法是按 R 鍵。此「完全重新啟動」操作會捨棄應用程式狀態,然後重建 UI。對於在程式碼變更後現有應用程式狀態仍然有效的情況,可以按 r 鍵進行「熱重載」,這只會重建 UI。IntelliJ IDEA 也有一個 Flutter 外掛,提供與 Dart 編輯器整合的相同功能:

IntelliJ IDEA 中使用 Flutter 外掛的螢幕截圖,顯示右上角的重新載入和重新啟動按鈕。如果應用程式是從 IDE 中啟動的,則這些按鈕會啟用。

重新啟動後,應用程式會顯示一個置中的文字標籤,顯示「Data set: null」,以及一個用於重新整理資料的浮動動作按鈕。是的,萬事起頭難。

要了解熱重載和完全重新啟動之間的區別,請嘗試以下操作:按下浮動動作按鈕幾次後,記下目前的資料集編號,然後在程式碼中將 Icons.refresh 替換為 Icons.add,儲存並執行熱重載。觀察按鈕的變化,但應用程式狀態會保留;我們仍然在隨機數字流中的同一個位置。現在撤消圖示變更,儲存並執行完全重新啟動。應用程式狀態已重設,我們回到了「Data set: null」。

我們的簡單應用程式展示了 Flutter Widget 概念的兩個核心面向:

  • 使用者介面是由不可變 Widget 樹定義的,該樹是透過建構函數調用(您可以在其中設定 Widget)和建構方法(Widget 實作可以決定其子樹的外觀)的組合建構的。我們應用程式的結果樹狀結構如下所示,其中每個 Widget 的主要角色都在括弧中。如您所見,雖然 Widget 概念相當廣泛,但每個具體的 Widget 類型通常都有一個非常專注的職責。
1
2
3
4
5
6
7
MaterialApp                    (導航)
ChartPage (狀態管理)
Scaffold (佈局)
Center (佈局)
Text (文字)
FloatingActionButton (使用者互動)
Icon (圖形)
  • 使用不可變 Widget 樹定義使用者介面後,變更該介面的唯一方法是重建樹。Flutter 會在下一幀到期時處理這件事。我們所要做的就是告訴 Flutter,子樹所依賴的某些狀態已變更。此類狀態相依子樹的根必須是 StatefulWidget。與任何正常的 Widget 一樣,StatefulWidget 不是可變的,但其子樹是由 State 物件建構的,State 物件是可變的。Flutter 會在樹重建過程中保留 State 物件,並在建構過程中將每個物件附加到新樹中各自的 Widget。然後,它們會決定如何建構該 Widget 的子樹。在我們的應用程式中,ChartPage 是一個 StatefulWidgetChartPageState 作為其狀態。每當使用者按下按鈕時,我們都會執行一些程式碼來變更 ChartPageState。我們已使用 setState 標記了變更,以便 Flutter 可以執行其內務處理並排程 Widget 樹以進行重建。當這種情況發生時,ChartPageState 將建構一個以新的 ChartPage 實例為根的稍微不同的子樹。

不可變 Widget 和狀態相依子樹是 Flutter 提供給我們的用於處理複雜 UI 中狀態管理複雜性的主要工具,這些 UI 會回應非同步事件,例如按鈕按下、計時器刻度或傳入資料。根據我的桌面經驗,我認為這種複雜性是非常真實的。評估 Flutter 方法的優勢是——也應該是——讀者的練習:在一些非平凡的事情上嘗試一下。

我們的圖表應用程式在 Widget 結構方面將保持簡單,但我們將做一些動畫的自訂圖形。第一步是用一個非常簡單的圖表替換每個資料集的文字表示。由於資料集目前只涉及 0..100 區間內的一個數字,因此圖表將是一個只有一個長條的長條圖,其高度由該數字決定。我們將使用初始值 50 以避免空高度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import 'package:flutter/material.dart';
import 'dart:math';

// ...

class ChartPageState extends State<ChartPage> {
int dataSet = 50; // Initial value

void _refreshData() {
setState(() {
dataSet = new Random().nextInt(101);
});
}

@override
Widget build(BuildContext context) {
return new Scaffold(
body: new Center(
child: new CustomPaint(
size: new Size(200.0, 200.0), // Adjust size as needed
painter: new BarChartPainter(dataSet),
)),
floatingActionButton: new FloatingActionButton(
onPressed: _refreshData,
tooltip: 'Refresh',
child: new Icon(Icons.refresh),
),
);
}
}

class BarChartPainter extends CustomPainter {
final int dataSet;
BarChartPainter(this.dataSet);

@override
void paint(Canvas canvas, Size size) {
final paint = new Paint()
..color = Colors.blue[400]
..style = PaintingStyle.fill;
canvas.drawRect(
new Rect.fromPoints(
new Offset(0.0, size.height - dataSet.toDouble()),
new Offset(size.width, size.height),
),
paint,
);
}

@override
bool shouldRepaint(BarChartPainter old) => old.dataSet != dataSet;
}

CustomPaint 是一個將繪製委託給 CustomPainter 策略的 Widget。我們對該策略的實作會繪製單個長條。

下一步是加入動畫。每當資料集變更時,我們希望長條平滑地而不是突然地變更高度。Flutter 有一個 AnimationController 概念來協調動畫,透過註冊一個監聽器,我們會在動畫值(從零到一的雙精度值)變更時收到通知。每當這種情況發生時,我們可以像以前一樣調用 setState 並更新 ChartPageState

出於說明原因,我們第一次嘗試會很醜陋:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import 'package:flutter/material.dart';
import 'package:flutter/animation.dart'; // Import animation library
import 'dart:math';

// ...

class ChartPageState extends State<ChartPage> with SingleTickerProviderStateMixin {
int startDataSet = 50;
int currentDataSet = 50;
int endDataSet = 50;

AnimationController _controller;
Animation<double> _animation;

@override
void initState() {
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = new Tween(begin: 0.0, end: 1.0).animate(_controller)
..addListener(() {
setState(() {
currentDataSet = (startDataSet + (endDataSet - startDataSet) * _animation.value).toInt();
});
});
}

void _refreshData() {
setState(() {
startDataSet = currentDataSet;
endDataSet = new Random().nextInt(101);
_controller.forward(from: 0.0);
});
}

@override
Widget build(BuildContext context) {
return new Scaffold(
body: new Center(
child: new CustomPaint(
size: new Size(200.0, 200.0),
painter: new BarChartPainter(currentDataSet),
)),
floatingActionButton: new FloatingActionButton(
onPressed: _refreshData,
tooltip: 'Refresh',
child: new Icon(Icons.refresh),
),
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}
}


// ... BarChartPainter remains the same

哎呦。複雜性已經開始顯現,而我們的資料集仍然只是一個數字!設定動畫控制所需的程式碼是一個小問題,因為當我們獲得更多圖表資料時,它不會產生分支。真正問題是變數 startHeightcurrentHeightendHeight,它們反映了對資料集和動畫值的變更,並且在三個不同的地方更新。

我們需要一個概念來處理這個爛攤子。

輸入 tweens。雖然遠非 Flutter 獨有,但它們是一個令人愉悅的簡單概念,用於組織動畫程式碼。它們的主要貢獻是用函數式方法取代了上面的命令式方法。tween 是一個_值_。它描述了在其他值的空間中兩個點之間的路徑,例如長條圖,因為動畫值從零到一。

Tweens 在這些其他值的類型中是通用的,並且可以用 Dart 表示為 Tween<T> 類型的物件:

1
2
3
4
5
6
7
8
class Tween<T> {
final T begin;
final T end;

Tween({ this.begin, this.end });

T lerp(double t) =&gt; T.lerp(begin, end, t);
}

術語 lerp 來自電腦圖形領域,是 線性插值(作為名詞)和 線性插值(作為動詞)的縮寫。參數 t 是動畫值,因此 tween 應該從 begin(當 t 為零時)到 end(當 t 為一時)進行插值。

Flutter SDK 的 Tween<T> 類別與上述非常相似,但它是一個支援變更 beginend 的具體類別。我不完全確定為什麼做出這個選擇,但在 SDK 的動畫支援領域中,我還沒有探索過,可能有很好的理由。在接下來的文章中,我將使用 Flutter Tween<T>,但假設它是不可變的。

我們可以使用單個 Tween<double> 來清理我們的長條高度程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import 'package:flutter/material.dart';
import 'package:flutter/animation.dart';
import 'dart:math';

// ...

class ChartPageState extends State<ChartPage> with SingleTickerProviderStateMixin {

Tween<double> _tween;

AnimationController _controller;
Animation<double> _animation;

@override
void initState() {
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);

_tween = new Tween(begin: 50.0, end: 50.0);
_animation = _tween.animate(_controller);

}

void _refreshData() {
setState(() {
_tween = new Tween(begin: _tween.end, end: new Random().nextInt(101).toDouble());
_controller.forward(from: 0.0);
});
}

@override
Widget build(BuildContext context) {
return new Scaffold(
body: new Center(
child: new CustomPaint(
size: new Size(200.0, 200.0),
painter: new BarChartPainter(_animation.value), // Use _animation.value directly
)),
floatingActionButton: new FloatingActionButton(

onPressed: _refreshData,
tooltip: 'Refresh',
child: new Icon(Icons.refresh),
),
);
}

// ... dispose method remains the same


}


class BarChartPainter extends CustomPainter {
final double dataSet; // Now takes a double
BarChartPainter(this.dataSet);

@override
void paint(Canvas canvas, Size size) {
final paint = new Paint()
..color = Colors.blue[400]
..style = PaintingStyle.fill;
canvas.drawRect(
new Rect.fromPoints(
new Offset(0.0, size.height - dataSet.toDouble()), // Convert to double if needed
new Offset(size.width, size.height),
),
paint,
);
}

@override
bool shouldRepaint(BarChartPainter old) => old.dataSet != dataSet;
}

我們使用 Tween 將長條高度動畫端點打包成單個值。它與 AnimationControllerCustomPainter 整合得很好,避免了動畫過程中 Widget 樹重建,因為 Flutter 基礎結構現在標記了 CustomPaint 以便在每個動畫刻度重新繪製,而不是標記整個 ChartPage 子樹以進行重建、重新佈局和重新繪製。這些都是明確的改進。但 tween 概念還有更多內容;它提供了_結構_來組織我們的想法和程式碼,而我們還沒有真正認真对待這一點。tween 概念說,

透過在所有 T 的空間中繪製路徑來製作 T 的動畫,因為動畫值從零到一。使用 Tween 建模路徑。

在上面的程式碼中,T 是一個雙精度值,但我們不希望對雙精度值進行動畫處理,我們希望對長條圖進行動畫處理!好吧,現在是單個長條,但這個概念很強大,如果我們允許的話,它可以擴展。

(您可能會想知道為什麼我們不進一步爭論,堅持對資料集而不是它們作為長條圖的表示進行動畫處理。這是因為資料集(與作為圖形物件的長條圖相反)通常不在存在平滑路徑的空間中。長條圖的資料集通常涉及對應於離散資料類別的數值資料。但是,如果沒有作為長條圖的空間表示,則不同類別的兩個資料集之間沒有合理的平滑路徑概念。)

回到我們的程式碼,我們將需要一個 Bar 類型和一個 BarTween 來對其進行動畫處理。接下來,讓我們將與長條相關的類別提取到它們自己的 bar.dart 檔案中,與 main.dart 並列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22


import 'package:flutter/animation.dart';
import 'package:flutter/material.dart';
import 'dart:ui' show lerpDouble;

class Bar {
final double height;
Bar({this.height});

static Bar lerp(Bar begin, Bar end, double t) {
return new Bar(height: lerpDouble(begin.height, end.height, t));
}
}

class BarTween extends Tween<Bar> {
BarTween({Bar begin, Bar end}) : super(begin: begin, end: end);

@override
Bar lerp(double t) => Bar.lerp(begin, end, t);
}

我在這裡遵循 Flutter SDK 約定,根據 Bar 類別上的靜態方法定義 BarTween.lerp。這對於像 BarColorRect 和許多其他簡單類型來說效果很好,但我們需要重新考慮更複雜圖表類型的方法。Dart SDK 中沒有 double.lerp,因此我們使用 dart:ui 套件中的函數 lerpDouble 來達到相同的效果。

我們的應用程式現在可以用長條表示,如下面的程式碼所示;我趁機取消了 dataSet 欄位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import 'package:flutter/material.dart';
import 'package:flutter/animation.dart';
import 'dart:math';
import 'bar.dart'; // Import bar classes


void main() => runApp(new ChartsApp());

// ... ChartsApp remains the same

class ChartPage extends StatefulWidget {
@override
ChartPageState createState() => new ChartPageState();
}

class ChartPageState extends State<ChartPage> with SingleTickerProviderStateMixin {

BarTween _barTween;
AnimationController _controller;
Animation<Bar> _animation;


@override
void initState() {
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);

_barTween = new BarTween(begin: new Bar(height:50.0), end: new Bar(height:50.0));
_animation = _barTween.animate(_controller);

}

void _refreshData() {
setState(() {

_barTween = new BarTween(
begin: _barTween.end, end: new Bar(height: new Random().nextInt(101).toDouble()));

_controller.forward(from: 0.0);


});
}

@override
Widget build(BuildContext context) {
return new Scaffold(
body: new Center(
child: new CustomPaint(
size: new Size(200.0, 200.0),
painter: new BarChartPainter(_animation.value), // Pass the Bar object
)),
floatingActionButton: new FloatingActionButton(
onPressed: _refreshData,
tooltip: 'Refresh',
child: new Icon(Icons.refresh),
),
);
}

@override
void dispose() {
_controller.dispose();
super.dispose();
}
}



class BarChartPainter extends CustomPainter {
final Bar bar;

BarChartPainter(this.bar);

@override
void paint(Canvas canvas, Size size) {
final paint = new Paint()
..color = Colors.blue[400]
..style = PaintingStyle.fill;
canvas.drawRect(
new Rect.fromPoints(
new Offset(0.0, size.height - bar.height),
new Offset(size.width, size.height),
),
paint,
);
}

@override
bool shouldRepaint(BarChartPainter old) => old.bar != bar;
}


新版本更長,額外的程式碼應該有其價值。在 第二部分 中,我們將處理增加的圖表複雜性時,它會發揮作用。我們的需求是彩色的長條、多個長條、部分資料、堆疊長條、分組長條、堆疊和分組長條,……所有這些都是動畫的。敬請期待。

我們將在第二部分中製作的動畫之一的預覽。


使用 Flutter 從零到一 最初發佈在 dartlang 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

使用 Dart 進行 hack

Python 的互動模式很棒。Dart 目前沒有互動模式,但它非常適合快速製作原型,所以讓我們看看是否可以將一些東西拼湊在一起!

聲明:我的確在 Google 工作,但這篇文章是關於一個個人專案。我不在 Dart 團隊或相關團隊。這只是我的淺見和我的故事。一起來吧。

像 Python 或 Ruby 等語言的互動模式有助於讓初學者更容易上手。但這些並不是唯一使用 REPL 概念的語言。REPL 是一個讀取-評估-列印循環。當您在終端機中與它們互動時,像 BASH 或 zsh 等 shell 也會使用 REPL。這也是您在 Jupyter (IPython Notebooks)、MatlabMathematicaMaple 中使用筆記本時所獲得的。這種互動式計算方式在研究人員中非常流行。

這就是 Python 互動模式的樣子:

Good ol’ Python REPL

Dart 不支援像 Python 那樣在全域範圍內評估語句,因此沒有明顯的方法可以正確地執行此操作。在 Dart 中,語句必須在函式內,而 main 函式是程式的進入點。就像在 C++ 或 C# 中一樣。就個人而言,我更喜歡這種方式,因為它更容易理解程式運行時發生的事情。但是… 我仍然非常想要一個互動模式。它可以讓我玩轉想法,更快地嘗試事物。那麼讓我們看看是否可以建立一個 REPL 作為概念驗證!

如何在 Dart 中建立 REPL?

Dart 非常適合製作原型。所以讓我們這樣做,不要被語言設計問題所困擾 :)

現在沒有像 JavaScript 或 Python 中那樣的 eval 函式,而且我也不想自己寫一個完整的直譯器來實現它。它不會很快,而且我也沒有很多時間。但是,當您在 Intellij 中除錯 Dart 程式碼 時,您可以在逐步執行程式碼時評估表達式。評估表達式正是我們想要做的,不是嗎?我們可以使用這個功能嗎?

Dart 的除錯功能透過其 VM 服務 公開。它是 Dart VM 提供的 JSON-RPC 服務,您可以連接到它來除錯您的應用程式。Natalie Weizenbaum 發表了一篇關於 vm_service_client 套件的文章,該套件提供了一個非常好的 API 來與 VM 服務互動:http://news.dartlang.org/2016/05/unboxing-packages-vmserviceclient.html

現在我們可以做的是:我們的 REPL 可以連接到它自己的 VM 服務來評估它從終端機讀取的表達式!這聽起來很瘋狂…

… 但它有效!我寫了一個 快速 spike,它確實有效。Dart 支援 非同步程式設計,這非常方便,因為它可以防止程式在與自己的 VM 服務通訊時阻塞自己。

Spike 是測試驅動開發中的一個概念:它們是用於找出一些技術問題的快速且粗糙的實驗。

解決了可行性問題後,很容易編寫一個合適的概念驗證。為了評估超出 1 + 1 的表達式,我們需要支援變數。現在無法輕鬆建立變數,因為變數宣告在 Dart 中不是表達式。在 Python 中,您可以透過賦值來即時宣告變數。在 Dart 中,我們可以透過重載 noSuchMethod 來即時在字典中建立元素,從而模擬動態欄位。我稱這個類別為 Scope,當我們在它的實例中 評估表達式 時,可以像全域變數一樣存取欄位。

程式碼實際上更直接:

有了這個,我們已經可以做一些事情,例如 a = 3b = a * 3

Simple expressions: DONE

我們很快就會遇到一個限制,那就是我們只能存取在宣告 Scope 類別的檔案中匯入的符號。import '...'; 無法使用 VM 服務進行評估。因此,如果我們沒有明確匯入 dart:io,就沒有 dart:io,也沒有自訂函式庫 :(

哦,等等!Dart 可以使用帶有 Isolate.spawnUri 的 URI 產生新的 isolates(獨立的工作線程)。使用者可以在命令列上指定額外的匯入,REPL 可以產生程式碼以包含這些匯入,然後使用產生的程式碼產生新的 isolate,這些程式碼具有使用者可用的匯入。

它有效 \o/

Custom imports: DONE

支援更多 Dart

現在的另一個問題是我們只能評估表達式。像 if/else 塊或 while 迴圈這樣的控制語句不是表達式。對於語句,我們可以將它們包裝在一個閉包中並執行該閉包,這是一個函式調用表達式。因此,if (a == 1) print('a is 1!!'); 將變成

1
() { if (a == 1) print('a is 1!!'); }();

我們只需要弄清楚輸入是表達式還是語句。這很困難,因為我們必須為此編寫一個 Dart 解析器。但是 Dart 是用 Dart 編寫的,而且 analyzer 套件提供了一個可以免費解析任何 Dart 程式碼的解析器!

因此,這也解決了。

Statements and expressions: DONE

更多匯入

最後一點阻止我們匯入任何函式庫的是,預設情況下,新的 Isolate 只能看到在其 pubspec.yaml 中提到的套件。我們希望支援匯入任何函式庫。Isolate.spawnUri 有一個 packageConfig 參數,允許我們指定從套件名稱到套件路徑的映射。我們可以使用另一個命令列參數來定位另一個套件,並在我們的 Isolate 中使用其套件設定。喔!

我們很快就會遇到問題,我們的 Isolate 需要存取 analyzer 套件(和其他套件),而這些套件可能不會被您想要在 REPL 會話中使用的任何套件載入。套件 package_resolver 來救援!有了它,我們可以輕鬆地操作套件設定。

有了這些,我們就實作了一個完整的工作流程:

Import any library from another package: DONE

接下來是什麼?

這是一篇很長的文章… 整個概念驗證的程式碼量為 451 行,幾乎和這篇文章一樣長。

程式碼可以在 https://github.com/BlackHC/dart_repl/tree/master/lib 上找到。如果您安裝了 Dart,您可以輕鬆地試用它:

1
2
pub global activate dart_repl
pub global run dart_repl

我真的很喜歡在我的閒暇時間建立這個概念驗證。所有部分都在幾個小時內就位。Intellij 中對 Dart 的 IDE 支援非常出色,而且現在有很多文件和文章。例如,請查看 Natalie Weizenbaum 的 Unboxing Packages 系列:http://news.dartlang.org/2016/04/unboxing-packages-async-part-3.html 等。Dart 中的低階 hack 很有趣,而且有很多很棒的函式庫可以發揮創意。code_builder 看起來非常有前途,built_collection 提供了不可變的集合。David Morgan 也一直在發表關於 Dart 中 不可變集合 的文章。

對於 dart_repl,如果可以在運行時匯入額外的函式庫而無需重新啟動,那就太好了。Dart 團隊最近在 VM 中加入了對熱重載的支援。這主要為 Flutter 中的行動應用程式開發人員提供了更好的體驗。也許,這也可以用於臨時匯入以及在 REPL 中定義函式和類別。

總體而言,Dart 對於研究和研究人員來說可能非常有用,我絕對希望看到 Jupyter 支援 Dart。只需要實作其核心介面,就可以使這樣的 Dart REPL 與之相容… :) 那應該很容易,對吧?


Dart REPL 概念驗證 最初發佈在 Medium 的 dartlang 上,人們在那裡透過醒目顯示和回應這個故事來繼續討論。

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

基於 LLVM 的 Dart 編譯實驗

這篇文章講述了一個使用 LLVM 編譯器框架編譯 Dart 語言的實驗。乍看之下,這似乎沒有什麼意義,因為:

Dart 已經擁有一個出色的 虛擬機器,它使用 即時編譯 (JIT) 來獲得優異的效能。由於 Dart 是動態型別的(更準確地說,它是 可選型別的),JIT 編譯器是一個很自然的選擇——它可以使用運行時可用的型別來執行靜態編譯器無法進行的優化。

Dart-on-LLVM 看起來像是一項徒勞無功的工作的另一個原因是,儘管名稱如此,LLVM 並不是一個虛擬機器,而且直到最近它才適用於帶有垃圾回收的語言。所謂適用,我們指的是:

  • 移動、精確(無洩漏)的垃圾回收
  • 高度優化

這是因為,一旦優化器修改了您的程式碼,您就再也無法找到堆疊上的可垃圾回收指標。一種常見的策略是將所有指標移動到特殊的記憶體區域,但这會降低現代編譯器中的許多優化策略,現代編譯器依賴於區域變數的暫存器分配來發揮其魔力。您可以擁有良好的垃圾回收或完全的效能,但不能兩者兼得。

然而,LLVM 領域正在颳起新的風。最近,LLVM 以實驗性的 Statepoint 功能的形式增加了一些垃圾回收支援。這已被各種勇敢的團隊使用,包括 LLV8 實驗背後的人員和 Azul,他們正在將其用於 JVM 的新型頂級編譯器。

構建一個基於 LLVM 的真正虛擬機器似乎已從「不可能的任務」變成了僅僅是「困難的任務」。同時,強模式 使 Dart 更具靜態型別,並且減少了動態性。此外,我們 Google 正在為 iOS 構建 Flutter,而 iOS 禁止 JIT 編譯。這兩個發展都使 Dart 更符合 LLVM 專案的目標和權衡。

為什麼選擇 LLVM?

LLVM 是一個現代的、維護良好的開源編譯器框架,它為我們提供了許多優化和平台,而且是「免費的」。例如,有一個完整的 內聯 pass,可以將任何函數內聯到任何其他函數中,並包含關於何時這樣做的 啟發式方法

它看起來也是一個開放、友好的社群,歡迎各種貢獻。

實驗目標

  • 上下文是在提前編譯場景中的強模式 Dart
  • 評估使用 Statepoint 支援精確、移動垃圾回收的可行性
  • 評估效能

方法

我們(Erik Corry 和 Dmitry Olshansky)的實驗基於已停止的「Dartino」運行時。這是一個針對小型設備進行了優化的實驗性 Dart 運行時。與使用 DartVM 作為基礎相比,它對我們有一些優勢:

  • 已經有一個由 Martin Kustermann 構建的 Dartino 的實驗性 LLVM 後端。它沒有垃圾回收支援,因此在記憶體不足時會崩潰。
  • Dartino 利用了 Dart2JS 的許多機制,因此它不需要完整的解析器、前端等。我們用作輸入的 Dartino 位元組碼已經降低了许多困難的 Dart 功能。例如,閉包是物件,可選參數已變成了不同版本的函數。
  • 我們都已經熟悉 Dartino。
  • Dartino 附帶了一個相對完整的運行時,並且能夠運行大型應用程式,例如託管 Dart2JS。它沒有很多 Unix IO 支援,而且執行緒模型也不同,因此它不是一個直接替代品。

Dartino 中的垃圾回收

現有的 Dartino LLVM 實驗是在一段時間之前從 Dartino 分叉出來的,當時的垃圾回收非常簡單(半空間 Cheney 收集器,沒有分代,長時間暫停,2 倍記憶體佔用開銷)。我們從 Dartino 主分支中挑選了一些變更,以獲得更傳統的帶有寫入屏障的 2 代垃圾回收。沒有讀取屏障,收集是停止所有執行緒的,沒有併發垃圾回收(雖然 LLVM Statepoints 似乎確實支援這些功能,而且它們幾乎肯定被 Azul 在其閉源虛擬機器中使用)。

我們沒有從較新的 Dartino 版本中挑選壓縮舊分代的支援。

架構

上面的流程顯示了從 Dart 原始碼到機器碼的路徑。在實際的實作中,第一部分將被基於「kernel」格式的內容替換(預先解析的 Dart 原始碼前端)。

翻譯成 LLVM 和高階優化

llvm-codegen 連結到我們自己的 LLVM 副本,並執行高階優化。在此階段,LLVM 維持指標在垃圾回收過程中有效的假設,但指標使用非預設的「地址空間」標記,這禁止 LLVM 以在存在移動垃圾回收的情況下不正確的方式推理其位元模式。各種自訂的 LLVM 內建函數用於標記可能發生垃圾回收的點。

由於標記指標,LLVM 位元組碼非常醜陋,有很多轉換和加法。因此,本文檔包含「LLVM 偽程式碼」,而不是真正的 .ll 檔案。如果您習慣於實際的 .ll 檔案,這看起來就像「寶貝的第一個 .ll 咿呀學語」,抱歉!以下程式碼表示 mem2reg 之後的動態分派,該 pass 將區域變數從堆疊提升到 SSA 暫存器中:

在優化器運行後,上面的相當費力的查找已從迴圈中移除,只剩下調用指令。這是可能的,因為類別指標在 Dart 中是不可變的,並且我們已將各種中繼資料附加到載入指令(未顯示),包括 invariant.loadnever.faults(後者是我們 修補的 LLVM 版本 的新增功能)。

降低

一旦高階優化運行完成,我們將大多數內建函數降低為普通的 LLVM 指令。例如,寫入屏障簡化為一系列儲存(Dartino 使用卡片標記方案,這很大程度上歸功於 Urs博士論文 第 6.2.3 節)。降低後,每個區域變數指標都在每個可能的垃圾回收點(基本上是每個調用)被一個不透明的內建函數重寫。這會抑制許多優化(這就是為什麼我們必須在降低之前進行優化 pass),但有兩個目的:

  • 內建函數稍後將用於生成堆疊映射,詳細說明堆疊上可垃圾回收指標的位置。
  • SSA 值被分解為垃圾回收前和垃圾回收後的值,這使得垃圾回收對優化器可見,並防止無效的程式碼生成。

調用現在看起來更像這樣(分派已從迴圈中提升出來,因此 %code 包含程式碼指標——迴圈未顯示)

轉換相當笨拙,在轉換後的調用中建立了一個特殊標記,並將其用作 gc.resultgc.relocate 調用中的參數。可垃圾回收指標仍然被特殊標記(使用非零地址空間,在上面的偽 LLVM 中未顯示),這在下一階段抑制了一些優化。

程式碼生成

最後一步是程式碼生成,由 LLVM 程式 llc 執行。這一步可以使用完全未修補的 ToT LLVM 透過命令 llc -O3 完成。目前唯一支援實驗性垃圾回收內建函數的後端是 x64,但我們沒有看到任何將 ARM 支援加入和上游化的根本性障礙。動態分派調用站點現在看起來像:

這使用 x64 的標準(主要是基於暫存器)調用約定。在每次調用之前,一堆暫存器會溢出到堆疊中,如果需要,垃圾回收可以在堆疊中移動它們。不支援被調用者儲存的可垃圾回收值(V8 和 DartVM 也不支援)。

效能

Dartino 位元組碼在一個非常動態的型別環境中針對簡潔性和緊湊性進行了優化。在此分析中,我們嘗試展望一個使用強模式並且在編譯時知道型別的場景。在這種情況下,方法的分派和對物件上成員變數的存取將更簡單、更快。為了更接近這種情況,我們在生成 LLVM 程式碼時使用了一些全程式分析。

最重要的結果是,如果只有少數類別具有方法 foo(),那麼我們會檢查這些類別並直接調用 foo() 方法。與某些類似虛擬函數表的分派機制不同,這讓 LLVM 可以在有意義的地方內聯方法。這是一個巨大的勝利,尤其是對於 getter 和 setter,這是 Dart 的一個很棒的功能。

編譯器仍然必須處理許多動態語言問題,它大多數都能正確處理(請參閱下面的測試狀態部分)。特別是,整數可能會溢出並隨時變成真正的堆分配數字物件。再加上運算子的重載,這使得即使是簡單的 for 迴圈也相當複雜。更多的靜態分析可能會改善這一點。

與真正的 DartVM 的一個區別是,我們不會檢查堆疊溢出,也不會在迴圈返回邊緣檢查執行緒中斷。根據 V8 的經驗,我們估計修復這個問題可能會損失大約 10% 的效能。

我們與常規的 JIT DartVM 以及已為 Flutter 加入到 DartVM 中的新的提前編譯支援進行了比較。基準測試 來自 Dartino

運行一個像 Hello World 這樣短暫的程式主要顯示啟動所需的時間。基於 JIT 的系統花費時間編譯程式碼,而這裡的兩個非 LLVM 解決方案都在啟動時反序列化資料堆。

效能結論

我們的效能與 Flutter 現有的提前編譯技術相當(這是一個移動的目標——這些測量是在 2016 年 11 月下旬在一台強大的 64 位 Linux 工作站上進行的)。JIT 仍然遙遙領先。我們正在運行的 Dartino 分支的垃圾回收效能還沒有達到標準。

我們還測量了啟動時間。Dartino-LLVM 為類別、常數和分派表生成靜態資料。這些資料由高度優化的 ld.linux 運行時連結器載入,它們的載入速度比目前的 Dart AOT 資料堆快照更快,從而為啟動提供了非常好的效能。對於啟動測試,CPU 調控器設定為「效能」。

關於相容性的說明

在這項研究中,我們並沒有特別關注獲得 100% 的 Dart 相容性。證明「困難的事情」是可能的,例如垃圾回收和異常處理,就足夠了。在某些情況下,我們採用了一種捷徑,表明真正的解決方案是可能的,而無需浪費時間實際實作真正的解決方案。以下是一些我們妥協的地方:

  • 像 Dartino 一樣,我們沒有無限精度的整數。但是,我們確實會檢查所有整數運算是否溢出,並動態切換到裝箱數字表示形式(但是,裝箱表示形式只有 64 位,會換行)。
  • 在 no-such-method(本質上是一個失敗的型別檢查)上,我們沒有遵循完整的 Dart 語義,這包括調用 no-such-method 方法並檢查是否存在與缺少的方法同名的 getter,並返回一個帶有「call」方法的物件。但是,我們確實會在安全點(可以進行分配的點)拋出異常。
  • 我們不會在調用時檢查堆疊溢出,也不會在迴圈返回邊緣檢查中斷。LLVM 確實對此提供了實驗性支援。我們比較的解決方案確實支援這一點。V8 的經驗表明,修復這個問題可能會導致大約 10% 的效能下降。
  • 我們的前端編譯器是一個修改過的 Dart2JS。由於 Dartino 已停止,它沒有跟上語言的最新變化,因此有一些測試我們無法運行。
  • Dart 異常處理已完全實作,除了與 no-such-method 相關的異常。為此,我們使用了 LLVM 內建的異常處理支援,這看起來足以勝任這項任務,並且與 Dart 的異常模型非常吻合(這與 LLVM 設計的 C++ 並沒有太大的不同)。

總之,我們通過了 Dartino 可以通過的近 90% 的測試。在我們失敗的測試中,最大的原因是編譯器前端的問題和處理 no-such-method 事件的問題。

在大約 11.6% 的失敗測試中,以下是它們失敗原因的細分:

結論

實驗性的 LLVM 垃圾回收支援似乎在 x64 上完全正常工作。

原型的效能與我們更成熟的基於 DartVM 的提前編譯解決方案相當。

對於效能分析,我們沒有使用 Dart 強模式,預計這將產生發揮 LLVM 優勢的優化機會。但是,我們正在使用一些封閉世界假設,我們認為這是現實的。

我們能夠僅使用未修補的 LLVM ToT 版本將最後階段從 LLVM 位元組碼編譯為機器碼(在上面的流程圖中以藍色標記)。我們觀察到,在此階段執行的優化 (-O3) 並沒有導致任何錯誤編譯或垃圾回收問題。

未來

關於如何以及是否將這種方法用於 Dart 或 Flutter 尚未做出決定,但以下是一些關於可以探索的有趣方向的隨機想法。

  • 擁有一種自定義的語言,而不是帶有控制代碼的 C++,來編寫運行時例程。後端將是帶有 Statepoint 的 LLVM。(目前的版本中有一個小的 Forth 實驗,但需要更強大的東西才能編寫非常簡單的原生例程)。
  • 包裝 64 位整數會產生什麼影響?
  • 我們如何使用全程式知識來生成程式碼,同時仍然允許並行編譯大型專案?

參考

LLVM 垃圾回收支援 http://llvm.org/docs/Statepoints.html
Dartino-LLVM 儲存庫 https://github.com/dartino/sdk/tree/llvm
修改後的 LLVM 儲存庫 https://github.com/ErikCorryGoogle/llvm
Urs Hölzle 博士論文: http://hoelzle.org/publications/urs-thesis.pdf
LLV8: https://github.com/ispras/llv8


基於 LLVM 的 Dart 最初發佈在 dartlang 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

Dart 的 built_value 序列化功能

上週我介紹了使用 built_value 建立不可變物件模型。我們看到了如何在 built_value 中定義物件模型;它們是不可變的,易於使用,而且,如果您喜歡這種東西,會有很多樂趣。

本文涵蓋了 built_value 套件的其餘部分。最大的項目是,正如您可能從標題中猜到的那樣,它們也是可序列化的。

以下是 built_value 序列化的使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 使用 built_value 定義的值類型。
abstract class Login implements Built<Login, LoginBuilder> {
// 透過定義此靜態 getter 來加入序列化支援。
static Serializer<Login> get serializer => _$loginSerializer;

...
}

// 每個應用程式一次,定義一個頂級的「Serializer」來收集所有生成的序列化器。
Serializers serializers = _$serializers;

// 使用它!
var login = Login((b) => b
..username = 'johnsmith'
..password = '123456');

print(JSON.encode(serializers.serialize(login)));
-->
["Login", "username", "johnsmith", "password", "123456"]

注意到「JSON.encode」了嗎?序列化器實際上並沒有序列化為字串;而是轉換為 Dart 內建的 JSON 序列化知道如何處理的原始類型。因此,如果您願意,可以使用 JSON 以外的其他格式。

您可能認為序列化應該「正常工作」,但其中涉及一些微妙的權衡。讓我們深入研究 built_value 的序列化。

多型性

built_value 序列化的最重要的一個方面是它支援多型性。具體來說,您可以擁有抽象類型的欄位,並且

  • 該抽象類型的任何可序列化實作都可以被序列化;
  • 足夠的資訊將被寫入網路,以便反序列化為正確的類型。

最簡單的例子是它可以序列化一個 Object 列表:

1
2
3
serializers.serialize(BuiltList<Object>([1, 'two', 3]));
-->
['list', ['int', 1, 'string', 'two', 'int', 3]]

僅在反序列化時需要消除歧義時,才會在網路上加入額外資訊。因此,如果您有一個類型為「BuiltList」的欄位,它將被序列化為「[1, 2, 3]」,而不是「[‘int’, 1, ‘int’, 2, ‘int’, 3]」。

底線是您可以隨意定義您的物件模型,built_value 將對其進行序列化。如果您想更詳細地了解這一點,map 序列化器測試 探索了所有可能性。

多種實作

所有序列化機制都必須面對的另一個問題是以某種方式定義可序列化類型的範圍。這裡 built_json 做了一些不尋常的事情,允許一個「類型」的多種實作

這是可行的,因為類型在網路上是僅透過其類別名稱來定義的。例如,沒有嘗試區分不同的名為「Login」的類別;假設發送方和接收方都有一個名為「Login」的類別的相容序列化器可用。

這增加了有用的靈活性。例如,如果您在伺服器和客戶端上使用 Dart,那麼您可以在物件模型中為每個類別進行選擇

  • 您可以在客戶端和伺服器上使用相同的類別。
  • 或者,您可以使用不同的類別。這些實作必須具有相同的名稱和相容的欄位。

例如,您可以為客戶端擁有一個處理渲染和解析的「Login」類別;以及為伺服器擁有一個處理驗證和資料庫的單獨的「Login」類別。當然,僅限伺服器的實作可以自由使用「dart:io」等套件,僅限客戶端的實作可以使用「dart:html」等套件。

多種語言

由於 built_value 序列化僅透過類別名稱來識別類型,因此序列化資料可以很好地映射到任何物件導向語言。透過 AutoValue 計劃支援 Java。

多個版本

序列化 built_value 資料以一種非常簡單的方式向後/向前相容:它依賴於類別名稱和欄位名稱。類別名稱變更和必要的欄位名稱變更會導致不相容。

可為空的欄位更靈活:在序列化時,只有在非空時才會寫入它們;在反序列化時,如果找不到它們,則預設為空。因此,可以加入、移除或重新命名可為空的欄位,這不是一個不相容的變更。

無法辨識的欄位將被忽略。

無反射

最後,對於效能至關重要的一點是,built_value 不以任何形式使用反射。所有分析都在程式碼生成時完成,為您留下最少、高效能的序列化程式碼。

這就是使用 built_value 進行序列化。您可以坐下來編寫物件模型,它可以直接序列化以用於 RPC 或長期儲存。

EnumClass

最後,built_value 還附帶一個功能:EnumClass。Dart 列舉不是類別,但一個強大的物件模型需要像類別一樣運作的列舉。顯而易見的模式是建立一個帶有「static const」欄位的類別,而 EnumClass 使這更容易做到。它提供:

  • 為「values」和「valueOf」生成的程式碼。
  • 透過 built_value 序列化器進行序列化。
  • 給 Angular 或 Angular2 使用者的額外獎勵:程式碼生成可以選擇性地生成一個 mixin,以幫助您在模板中使用列舉。

所有這些功能都可以在範例 中看到。

本週就到這裡了!在介紹了 built_value 的基礎知識之後,我準備在下週詳細介紹聊天範例。敬請關注!

編輯:下一篇文章


Dart 的 built_value 序列化功能 最初發佈在 Medium 的 dartlang 上,人們在那裡透過突出顯示和回應這個故事來繼續討論。

【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】

Dart 的 built_value,實現不可變物件模型

上週我寫了關於 built_collection 的文章。最後我提到,要真正使用不可變集合,您需要不可變值。所以我們現在來談談 built_value。這是我在 Dart 開發者峰會 (影片) 上演講的第二個主要部分。

值類型

built_value 套件用於定義您自己的值類型。這個術語有精確的含義,但我們非正式地使用它來表示僅基於值判斷相等性的類型。例如,數字:我的 3 等於你的 3。

不僅如此:我的 3 將永遠等於你的 3;它不能變成 4,也不能變成 null,更不能變成完全不同的類型。值類型天生就是不可變的。這使得它們易於互動和推理。

這聽起來非常抽象。值類型有什麼用?事實證明:有很多用處。非常多。可以說——而且我經常這樣說——任何用於模擬現實世界的類都應該是值類型。觀察:

1
2
3
4
var user1 = User(name: "John Smith");
var user2 = User(name: "John Smith");

print(user1 == user2);

它應該印出什麼?關鍵是,這兩個實例都應該指向現實世界中的某個人。因為它們的值相同,所以它們必須指向同一個人。所以它們必須被認為是相等的。

那麼不可變性呢?考慮:

1
user1.nickname = 'Joe';

更新「使用者」暱稱意味著什麼?它可能意味著任何數量的更改;也許我的網頁上的歡迎文字使用了暱稱,那也應該更新。我可能在某個地方有一些儲存空間,那也需要更新。我現在有兩個主要問題:

  • 我不知道誰持有對 user1 的引用。該值在它們下面剛剛更改;根據它們的使用方式,這可能會產生任何數量不可預測的影響。
  • 任何持有 user2 或類似值的人現在都持有一個過時的值。

不可變性無法解決第二個問題,但它確實消除了第一個問題。這意味著沒有不可預測的更新,只有明確的更新:

1
2
var updatedUser = User(name: "John Smith", nickname: "Joe");
saveToDatabase(updatedUser); // 資料庫將通知前端。

關鍵是,這意味著更改是局部的,直到明確發佈。這會產生易於推理的簡單程式碼——並且使程式碼既正確又快速。

值類型的問題

那麼,顯而易見的問題是:如果值類型如此有用,為什麼我們沒有到處看到它們?

不幸的是,它們的實作非常費力。在 Dart 和大多數其他物件導向語言中,需要大量的樣板程式碼。在我於 Dart 開發者峰會上的演講中,我展示了一個簡單的雙欄位類需要如此多的樣板程式碼,以至於佔滿了整張投影片 (影片)。

介紹 built_value

我們需要一個語言特性——討論起來令人興奮,但不太可能很快出現——或者某種形式的元程式設計。我們發現 Dart 已經有一種非常好的元程式設計方法:source_gen

目標很明確:使定義和使用值類型變得如此容易,以至於我們可以在任何值類型有意義的地方使用它們。

首先,我們需要快速繞道,看看如何使用 source_gen 處理這個問題。source_gen 工具在您手動維護的原始碼旁邊的新檔案中建立生成的原始碼,因此我們需要為生成的實作留出空間。這意味著一個抽象類別:

1
2
3
4
5
6
abstract class User {
String get name;

@nullable
String get nickname;
}

這有足夠的資訊來產生一個實作。按照慣例,生成的程式碼以 _$ 開頭,以標記它是私有的和生成的。因此生成的實作將被稱為 _$User。為了允許它擴展 User,將有一個名為 _ 的私有建構函數用於此目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
=== user.dart ===

abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User() = UserImpl;
}

=== user.g.dart 由 source_gen 產生 ===

class _$User extends User {
String name;
String nickname;

_$User() : super._();
}

我們需要使用 Dart 的 part 陳述式來引入生成的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
=== user.dart ===

library user;

part 'user.g.dart';

abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User() = _$User;
}

=== user.g.dart 由 source_gen 產生 ===

part of user;

class _$User extends User {
String name;
String nickname;

_$User() : super._();

// 生成的實作放在這裡。
}

我們正在取得進展!我們有一種方法可以產生程式碼,並將其插入到我們手寫的程式碼中。現在回到有趣的部分:您實際上手寫的內容,以及 built_value 應該生成的內容。

我們缺少一種實際指定欄位值的方法。我們可以考慮使用具名可選參數:

1
factory User({String name, String nickname}) = _$User;

但這有幾個缺點:它強迫您在建構函數中重複所有欄位名稱,並且它只提供了一種一次設定所有欄位的方法;如果您想逐個構建值怎麼辦?

幸運的是,建構器模式 來拯救我們了。我們已經看到它在 Dart 中的集合中效果如何——感謝串聯運算符。假設我們有一個建構器類型,我們可以使用它作為建構函數——透過請求一個將建構器作為參數的函數:

1
2
3
4
5
6
7
8
9
abstract class User {
String get name;

@nullable
String get nickname;

User._();
factory User([updates(UserBuilder b)]) = _$User;
}

這有點令人驚訝,但它導致了一個非常簡單的實例化語法:

1
2
3
var user1 = User((b) => b
..name = 'John Smith'
..nickname = 'Joe');

如何根據舊值建立新值?傳統的建構器模式提供了一個 toBuilder 方法來轉換為建構器;然後您應用您的更新並調用 build。但對於大多數使用案例來說,一個更好的模式是有一個 rebuild 方法。與建構函數一樣,它接受一個以建構器為參數的函數,並提供簡單的內聯更新:

1
2
var user2 = user1.rebuild((b) => b
..nickname = 'Jojo');

不過,我們仍然需要 toBuilder,以防您想將建構器保留一段時間。因此,我們希望所有值類型都有兩種方法:

1
2
3
4
5
6
7
abstract class Built<V, B> {
// 建立一個新的實例:應用 [updates] 的實例。
V rebuild(updates(B builder));

// 轉換為建構器。
B toBuilder();
}

您不需要為這些方法編寫實作,built_value 將為您產生它。因此,您只需聲明您「實作 Built」:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
library user;

import 'package:built_value/built_value.dart';

part 'user.g.dart';

abstract class User implements Built<User, UserBuilder> {
String get name;

@nullable
String get nickname;

User._();
factory User([updates(UserBuilder b)]) = _$User;
}

就這樣!定義了一個值類型,產生了一個實作,並且易於使用。當然,生成的實作不僅僅是欄位:它還提供了 operator==hashCodetoString 以及對必要欄位的 null 檢查。

不過,我跳過了一個主要細節:我說「假設我們有一個建構器類型」。當然,我們正在產生程式碼,所以答案很簡單:我們會為您產生它。User 中引用的 UserBuilder 是在 user.g.dart 中建立的。

除非您想在建構器中編寫一些程式碼,這是一個非常合理的做法。如果您想這樣做,您可以對建構器遵循相同的模式。它被聲明為抽象的,有一個私有建構函數和一個委託給生成的實作的工廠:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract class UserBuilder implements Builder<User, UserBuilder> {
@virtual
String name;

@virtual
String nickname;

// 將例如 John "Joe" Smith 解析為使用者名稱 + 暱稱。
void parseUser(String user) {
...
}

UserBuilder._();
factory UserBuilder() = _$UserBuilder;
}

@virtual 註釋來自 package:meta,並且是允許生成的實作覆寫欄位所必需的。現在您已經將工具方法加入到您的建構器中,您可以像將其賦值給欄位一樣內聯使用它們:

1
var user = User((b) => b..parseUser('John "Joe" Smith'));

自訂建構器的使用案例相對少見,但它們可以非常強大。例如,您可能希望您的建構器實作一個用於設定共用欄位的通用介面,以便它們可以互換使用。

嵌巢建構器

built_value 還有一個您還沒有看到的主要功能:嵌巢建構器。當一個 built_value 欄位持有 built_collection 或另一個 built_value 時,預設情況下,它在建構器中作為嵌巢建構器提供。這意味著您可以更容易地更新深層嵌套的欄位,而不是整個結構都是可變的:

1
2
3
4
5
6
7
8
9
10
var structuredData = Account((b) => b
..user.name = 'John Smith'
..user.nickname = 'Joe'
..credentials.email = '[email protected]'
..credentials.phone.country = Country.us
..credentials.phone.number = '555 01234 567');

var updatedStructuredData = structuredData.rebuild((b) => b
..credentials.phone.country = Country.switzerland
..credentials.phone.number = '555 01234 555');

為什麼比整個結構都是可變的「更容易」?

首先,所有建構器提供的 update 方法意味著您可以隨時進入新的作用域,「重新啟動」串聯運算符,並簡潔地內聯進行您想要的任何更新:

1
2
3
4
5
6
var updatedStructuredData = structuredData.rebuild((b) => b
..user.update((b) => b
..name = 'Johnathan Smith')
..credentials.phone.update((b) => b
..country = Country.switzerland
..number = '555 01234 555'));

其次,嵌套建構器會根據需要自動建立。例如,在 built_value 的基準測試程式碼中,我們定義了一個名為 Node 的類型:

1
2
3
4
5
6
7
8
9
10
11
abstract class Node implements Built<Node, NodeBuilder> {
@nullable
String get label;
@nullable
Node get left;
@nullable
Node get right;

Node._();
factory Node([updates(NodeBuilder b)]) = _$Node;
}

建構器的自動建立讓我們可以內聯建立我們想要的任何樹結構:

1
2
3
4
5
6
7
var node = Node((b) => b
..left.left.left.right.left.right.label = 'I’m a leaf!'
..left.left.right.right.label = 'I’m also a leaf!');

var updatedNode = node.rebuild((b) => b
..left.left.right.right.label = 'I’m not a leaf any more!'
..left.left.right.right.right.label = 'I’m the leaf now!');

我提到基準測試了嗎?更新時,built_value 只複製需要更新的結構部分,重用其餘部分。所以它很快——而且記憶體效率很高*。

但您不必只構建樹。使用 built_value,您可以使用完全類型的不可變物件模型…它們與高效的不可變樹一樣快速和強大。您可以混合和匹配類型資料、自訂結構(如 Node 範例)以及來自 built_collection 的集合:

1
2
3
4
5
6
7
8
9
10
11
var structuredData = Account((b) => b
..user.update((b) => b
..name = 'John Smith')
..credentials.phone.update((b) => b
..country = Country.us
..number = '555 01234 567')
..node.left.left.left.account.update((b) => b
..user.name = 'John Smith II'
..user.nickname = 'Is lost in a tree')
..node.left.right.right.account.update((b) => b
..user.name = 'John Smith III'));

當我說大多數資料都應該是值類型時,我說的就是這些值類型!

更多關於 built_value

我已經討論了為什麼需要 built_value 以及它的使用方法。還有更多:built_value 還提供了 EnumClass,用於像列舉一樣的類別,以及 JSON 序列化,用於伺服器/客戶端通訊和資料儲存。我將在以後的文章中討論這些內容。

之後,我將深入研究聊天範例,該範例在具有伺服器和客戶端的端到端系統中使用 built_value

編輯:下一篇文章


Dart 的 built_value,實現不可變物件模型 最初發佈於 dartlang 的 Medium 上,人們在那裡透過醒目顯示和回應這個故事來繼續對話。