【文章內容使用 Gemini 1.5 Pro 自動翻譯產生】
使用 Flutter 從零到一 2016 年夏末,我在丹麥奧胡斯 Google 辦公室的新工作是使用 Flutter 和 Dart 在 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 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 2 3 4 5 charts android ios lib main.dart
已產生大約五十個檔案,組成一個完整的範例應用程式,可以安裝在 Android 和 iOS 上。我們將在 main.dart
和同級檔案中完成所有程式碼編寫,無需修改任何其他檔案或目錄。
您應該驗證是否可以啟動範例應用程式。啟動模擬器或連接設備,然後在 charts
目錄中執行
然後,您應該在模擬器或設備上看到一個簡單的計數應用程式。它使用了 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
是一個 StatefulWidget
,ChartPageState
作為其狀態。每當使用者按下按鈕時,我們都會執行一些程式碼來變更 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 ; 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 ), 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 '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(); } }
哎呦。複雜性已經開始顯現,而我們的資料集仍然只是一個數字!設定動畫控制所需的程式碼是一個小問題,因為當我們獲得更多圖表資料時,它不會產生分支。真正問題是變數 startHeight
、currentHeight
和 endHeight
,它們反映了對資料集和動畫值的變更,並且在三個不同的地方更新。
我們需要一個概念來處理這個爛攤子。
輸入 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) => T.lerp(begin, end, t); }
術語 lerp
來自電腦圖形領域,是 線性插值 (作為名詞)和 線性插值 (作為動詞)的縮寫。參數 t
是動畫值,因此 tween 應該從 begin
(當 t
為零時)到 end
(當 t
為一時)進行插值。
Flutter SDK 的 Tween<T>
類別與上述非常相似,但它是一個支援變更 begin
和 end
的具體類別。我不完全確定為什麼做出這個選擇,但在 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), )), floatingActionButton: new FloatingActionButton( onPressed: _refreshData, tooltip: 'Refresh' , child: new Icon(Icons.refresh), ), ); } } class BarChartPainter extends CustomPainter { final double 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; }
我們使用 Tween
將長條高度動畫端點打包成單個值。它與 AnimationController
和 CustomPainter
整合得很好,避免了動畫過程中 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
。這對於像 Bar
、Color
、Rect
和許多其他簡單類型來說效果很好,但我們需要重新考慮更複雜圖表類型的方法。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' ; void main() => runApp(new ChartsApp());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), )), 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,人們在那裡透過突出顯示和回應這個故事來繼續討論。