干了兩年后端,最近才開(kāi)始接觸前端技術(shù),而且我沒(méi)有選擇網(wǎng)頁(yè)開(kāi)發(fā)三件套。
主要原因是我已經(jīng)接近了 PyWebIO 的能力極限,前段時(shí)間又發(fā)現(xiàn)了一個(gè)新的 UI 庫(kù) Flet,簡(jiǎn)單跑了下 Demo,看了看實(shí)現(xiàn)原理,發(fā)現(xiàn)底層是 Flutter。
于是我想著,F(xiàn)let 目前還不支持封裝移動(dòng)端應(yīng)用,網(wǎng)頁(yè)端應(yīng)用的性能也一般,不如去學(xué)一下 Flutter,點(diǎn)一下前端開(kāi)發(fā)技能。
于是十一假期在 B 站上找了個(gè)教程,開(kāi)始邊看邊寫,也讀了一些官方文檔,到現(xiàn)在有一個(gè)月了,總算是刷完了教程(一共 24 小時(shí)的視頻),學(xué)了幾十個(gè)組件的常用參數(shù),踩了一些坑,自己做出了一點(diǎn)東西。
這是一篇技術(shù)文章,讀者最好熟悉至少一門編程語(yǔ)言,并對(duì)異步編程有基本的了解,如果之前接觸過(guò)前端開(kāi)發(fā),上手 Flutter 應(yīng)該不會(huì)太難。
這篇教程中,我們將開(kāi)發(fā)一個(gè)跨平臺(tái)應(yīng)用,實(shí)現(xiàn)對(duì)簡(jiǎn)書大轉(zhuǎn)盤中獎(jiǎng)列表的展示。
Flutter 是什么
一句話介紹:它是 Google 推出的一套跨平臺(tái)開(kāi)發(fā)框架,可以在桌面端、移動(dòng)端和網(wǎng)頁(yè)端上構(gòu)建高性能的用戶界面。
也就是說(shuō),我們只需要寫一份代碼,F(xiàn)lutter 會(huì)幫你處理不同平臺(tái)的底層差異,幫你完成應(yīng)用打包,最后生成不同平臺(tái)的可執(zhí)行文件。
不同于基于網(wǎng)頁(yè)(HTML5)的跨平臺(tái)技術(shù),F(xiàn)lutter 應(yīng)用會(huì)被編譯成對(duì)應(yīng)平臺(tái)的機(jī)器碼,因此擁有更高的性能與更小的內(nèi)存占用,在優(yōu)化得當(dāng)?shù)那闆r下,可以輕松達(dá)到 60 幀甚至更高的幀率。
對(duì)于部分依賴原生代碼的功能,F(xiàn)lutter 提供了與平臺(tái)原生語(yǔ)言良好的互操作性,你可以將部分邏輯用原生語(yǔ)言實(shí)現(xiàn),然后整合到 Flutter 中。
閑魚 App 大量使用 Flutter 進(jìn)行開(kāi)發(fā),騰訊和字節(jié)跳動(dòng)也在旗下產(chǎn)品中使用了 Flutter 與原生混合開(kāi)發(fā)的架構(gòu)。
目前,Google Play 上已經(jīng)有超過(guò) 50 萬(wàn)款應(yīng)用程序使用 Flutter,它已成為最流行的跨平臺(tái)開(kāi)發(fā)框架。
環(huán)境配置
Flutter 的開(kāi)發(fā)環(huán)境相比 Python 略顯復(fù)雜,因?yàn)橐С?Android / iOS 的交叉編譯,需要下載對(duì)應(yīng)的 SDK。
這里要預(yù)先說(shuō)明的是,iOS 和 MacOS 應(yīng)用只能在 MacOS 上構(gòu)建,但 Android 應(yīng)用沒(méi)有這個(gè)限制,在 MacOS 上也可以構(gòu)建。
官方的 安裝指南 寫的比較詳細(xì),可以作為參照。
建議預(yù)留 10GB 硬盤空間用于安裝,如果要使用 Android 模擬器,空間需求可能會(huì)更大。
下面簡(jiǎn)述 Windows 和 Linux 平臺(tái)的環(huán)境配置過(guò)程。
Windows
官方文檔:https://docs.flutter.dev/get-started/install/windows
在官方文檔中下載最新的 Flutter SDK ,將其解壓到一個(gè)目錄下,盡量避免路徑中出現(xiàn)特殊字符。
更新系統(tǒng)環(huán)境變量,加入 flutter/bin 文件夾對(duì)應(yīng)的路徑。
在命令提示符中輸入 flutter --version,看到版本信息輸出即配置成功。
現(xiàn)在,你已經(jīng)可以正常構(gòu)建 Windows 和 Web 應(yīng)用了,但如果要構(gòu)建 Android 應(yīng)用,還需要配置 Android 開(kāi)發(fā)環(huán)境。
下載并安裝 Android Studio,這一步是必須的,但在后續(xù)的開(kāi)發(fā)過(guò)程中,你可以使用任何自己喜歡的編輯器 / IDE。
在安裝過(guò)程中,會(huì)要求你選擇 Android SDK 版本,一般選擇最新版即可,F(xiàn)lutter 框架的最低支持系統(tǒng)版本與這一選項(xiàng)無(wú)關(guān),更高的 SDK 版本有助于提高性能。
參考 這篇文章 下載并安裝 Android 設(shè)備的 USB 驅(qū)動(dòng),F(xiàn)lutter SDK 自帶了 Android 真機(jī)調(diào)試所需的 adb 工具,不需要單獨(dú)下載。
在命令提示符中執(zhí)行以下命令,根據(jù)指引同意 Android SDK 相關(guān)許可協(xié)議:
flutter doctor --android-licenses
如果你需要設(shè)置 Android 模擬器,請(qǐng)參考 官方文檔的相應(yīng)章節(jié)。
在手機(jī)上打開(kāi)開(kāi)發(fā)者模式,啟用 USB 調(diào)試,將手機(jī)連接到電腦,之后執(zhí)行以下命令:
flutter devices
如果能在輸出中找到你的 Android 設(shè)備,則代表配置無(wú)誤。
構(gòu)建 Web 應(yīng)用程序時(shí),Chrome 瀏覽器會(huì)提供最好的兼容性和體驗(yàn),但這不是必須的。如果需要,你可以在 這里 下載。
最后,執(zhí)行以下命令,進(jìn)行最終的環(huán)境自檢:
flutter doctor
你會(huì)看到類似這樣的輸出:(以下為 Linux 系統(tǒng)的輸出)
Doctor summary (to see all details, run flutter doctor -v):
[?] Flutter (Channel beta, 3.4.0-34.1.pre, on Manjaro Linux 6.1.0-1-MANJARO, locale zh_CN.UTF-8)
[?] Android toolchain - develop for Android devices (Android SDK version 33.0.0)
[?] Chrome - develop for the web
[?] Linux toolchain - develop for Linux desktop
[?] Android Studio
[?] Connected device (2 available)
[?] HTTP Host Availability
? No issues found!
Linux
如果你想偷懶:使用 Snap 一鍵完成 Flutter SDK 安裝
官方文檔:https://docs.flutter.dev/get-started/install/linux
同樣的,你需要下載 Flutter SDK,將其解壓到一個(gè)文件夾下,建議放置在 /home/your_user_name 或其它不需要 Root 權(quán)限的目錄。
接下來(lái),需要添加環(huán)境變量,你可以選擇將其加入你的 Shell 配置文件,或通過(guò)其它方式完成配置。
完成操作后,source 對(duì)應(yīng)配置文件或重新打開(kāi)終端,輸入 flutter --version,確認(rèn)環(huán)境變量配置無(wú)誤。
用相同的方式安裝 Android Studio,這里你可能會(huì)遇到關(guān)于 JDK 和 JRE 版本沖突的問(wèn)題,請(qǐng)善用搜索引擎。
用相同的方式將 Android Studio 的路徑加入環(huán)境變量,最好一并指定 JAVA_HOME。
記得同意許可協(xié)議,如果遇到無(wú)法找到目錄的問(wèn)題,可通過(guò)臨時(shí)設(shè)置環(huán)境變量解決。
運(yùn)行 flutter doctor,確認(rèn)環(huán)境配置無(wú)誤。
如果你遇到 Linux Toolchain 缺失的問(wèn)題,請(qǐng)通過(guò)包管理器補(bǔ)全相應(yīng)依賴。
至此,你已經(jīng)可以構(gòu)建所有受支持平臺(tái)的應(yīng)用程序了,但還有一些事情需要完成。
配置 IDE / 編輯器
Android Studio 對(duì) Flutter 有開(kāi)箱即用的支持。
對(duì)于 VS Code,需要安裝 Dart 和 Flutter 插件。
建議一并安裝 Awesome Flutter Snippets 插件,它提供了很多 Flutter 代碼片段,可以大大提升開(kāi)發(fā)效率。
配置國(guó)內(nèi)鏡像源
對(duì)于國(guó)內(nèi)開(kāi)發(fā)者,F(xiàn)lutter 默認(rèn)的軟件包源速度可能不理想,這會(huì)在一定程度上增加應(yīng)用構(gòu)建所需的時(shí)間。
在 Windows 下,設(shè)置以下環(huán)境變量:
PUB_HOSTED_URL=https://pub.flutter-io.cn
FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
在 Linux 下,將以下內(nèi)容添加到 Shell 配置文件:
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn
更改鏡像源后,當(dāng)有涉及網(wǎng)絡(luò)請(qǐng)求的操作時(shí),將輸出以下提示:
Flutter assets will be downloaded from https://storage.flutter-io.cn. Make sure you trust this
source!
這是正?,F(xiàn)象,無(wú)需理會(huì)。
預(yù)下載資源文件
為了提升構(gòu)建速度,建議在空閑時(shí)間或連接 Virtual Private Network 后執(zhí)行以下命令,預(yù)下載 Flutter SDK 資源文件:
flutter precache
運(yùn)行示例項(xiàng)目
新建一個(gè)文件夾,用于存放 Flutter 項(xiàng)目。
在終端中打開(kāi)該文件夾,執(zhí)行以下命令,新建一個(gè)名為 demo 的項(xiàng)目。
flutter create demo
進(jìn)入剛剛新建的 demo 文件夾,執(zhí)行命令:
flutter run
選擇對(duì)應(yīng)平臺(tái),等待應(yīng)用構(gòu)建完成。
你會(huì)看到如下所示的應(yīng)用:

點(diǎn)擊右下角的按鈕,數(shù)字會(huì)隨之增長(zhǎng)。
如果你愿意,現(xiàn)在就可以構(gòu)建 .apk 文件,安裝到 Android 設(shè)備上:
flutter build apk
構(gòu)建完成后,可以在 ./build/app/outputs/flutter-apk/ 找到 .apk 文件。
熱重載
在原生 Android 開(kāi)發(fā)中,從修改到新的應(yīng)用完成編譯并在設(shè)備上運(yùn)行,需要幾十秒到幾分鐘的時(shí)間。
Flutter 相比原生開(kāi)發(fā)的一個(gè)顯著優(yōu)勢(shì),就是可以使用熱重載快速預(yù)覽效果。
在觸發(fā)熱重載后,F(xiàn)lutter 會(huì)重新編譯有變化的文件,并觸發(fā)所有組件的重新構(gòu)建。
打開(kāi) demo 項(xiàng)目的 lib/main.dart 文件,同時(shí)啟動(dòng)應(yīng)用。
如果你使用 VS Code 進(jìn)行開(kāi)發(fā),需要在左側(cè)“運(yùn)行和調(diào)試”面板中運(yùn)行項(xiàng)目。
找到數(shù)字上方的提示語(yǔ),在我的示例中位于第 99 行,將其改為以下內(nèi)容:
'您已點(diǎn)擊過(guò)的次數(shù):'
在 VS Code 中,按下 Ctrl + S 或 Ctrl + F5,或在終端中輸入 r,會(huì)看到以下提示:
Performing hot reload...
Reloaded 1 of 623 libraries in 400ms (compile: 41 ms, reload: 198 ms, reassemble: 112 ms).
同時(shí),你會(huì)發(fā)現(xiàn)應(yīng)用中的顯示發(fā)生了變化。
注意,在部分場(chǎng)景下,熱重載可能不起作用,此時(shí)需要使用熱重啟,VS Code 快捷鍵為 Ctrl + Shift + F5,或在命令行輸入 R。
如果熱重啟依然不起作用,則需要重新運(yùn)行項(xiàng)目。
項(xiàng)目目錄結(jié)構(gòu)
Flutter 會(huì)在創(chuàng)建項(xiàng)目時(shí)建立很多文件夾:
android、ios、windows、macos、linux、web 這些文件夾中存放的是對(duì)應(yīng)平臺(tái)的文件,如果想編寫平臺(tái)原生代碼,或自定義對(duì)應(yīng)平臺(tái)的構(gòu)建方式,需要在這些位置修改。
在創(chuàng)建項(xiàng)目時(shí),可以指定項(xiàng)目支持的平臺(tái):
flutter create demo --platforms=android,ios
這樣可以創(chuàng)建一個(gè)只支持 Android 和 iOS 平臺(tái)的項(xiàng)目,這時(shí),其它平臺(tái)對(duì)應(yīng)的文件夾不會(huì)被創(chuàng)建。
如果你需要在后期添加更多平臺(tái)的支持,可以使用以下命令:
flutter create . --platforms=windows,macos
test 文件夾內(nèi)保存了應(yīng)用測(cè)試的相關(guān)代碼。
build 文件夾內(nèi)存放著應(yīng)用的構(gòu)建緩存和產(chǎn)物。
.gitignore 是版本管理系統(tǒng)的忽略文件。
analysis_options.yaml 是代碼風(fēng)格檢查的配置文件。
your-project-name.iml 文件是供 IntelliJ IDEA 使用的,如果你不使用這個(gè) IDE 進(jìn)行開(kāi)發(fā),可以忽略。
README.md 文件存放著你的項(xiàng)目簡(jiǎn)介。
pubspec.yaml 存儲(chǔ)著項(xiàng)目依賴、名稱、版本等重要配置,我們會(huì)在后文中用到這個(gè)文件。
.metadata 和 pubspec.lock 是依賴管理的鎖定文件,會(huì)隨著依賴變動(dòng)自動(dòng)更新。它們需要被添加到版本管理,以保證協(xié)作開(kāi)發(fā)時(shí)的環(huán)境一致,但一般不需要手動(dòng)更改其內(nèi)容。
你的應(yīng)用程序代碼存放在 lib 目錄下。
Dart
Dart 是編寫 Flutter 應(yīng)用使用的語(yǔ)言。
它是一個(gè)強(qiáng)類型的靜態(tài)語(yǔ)言,但也可以聲明動(dòng)態(tài)類型。
語(yǔ)法方面,更像是 C 和 Javascript 的結(jié)合體,支持 async、await 異步語(yǔ)法,條件判斷語(yǔ)句則使用了 C 中的大括號(hào)寫法。
Dart 擁有空安全特性,你可以這樣定義一個(gè)可空的 int:
int? a = 1
這樣定義一個(gè)不可空的 int:
int a = 1
使用類型斷言,強(qiáng)制要求 int 不能為空:
print(a!)
或只在 int 不為空時(shí)才運(yùn)行對(duì)應(yīng)代碼:
print(a?.toString())
Dart 語(yǔ)法并不復(fù)雜,讀者可以先去閱讀基礎(chǔ)教程,也可以直接跳過(guò)這一部分,在后續(xù)的應(yīng)用開(kāi)發(fā)中逐漸了解。
Flutter Widget
Flutter 中萬(wàn)物皆 Widget,所謂 Widget 就是界面中的一個(gè)元素,大到整個(gè)應(yīng)用的基礎(chǔ)結(jié)構(gòu),小到一個(gè)文本塊,本質(zhì)上都是 Widget,它們的嵌套與組合構(gòu)成了整個(gè)界面。
例如,這是 Flutter 中顯示一個(gè)文本塊的代碼:
Text("Hello World")
將它放置在應(yīng)用中,是這樣的:

文本出現(xiàn)在了左上方,并且應(yīng)用了默認(rèn)的樣式。
現(xiàn)在我們想讓這個(gè)元素居中,對(duì)應(yīng)的 Widget 是 Center,我們將 Text 組件用 Center 包裹,在 Flutter 中,組件嵌套時(shí)需要傳入單個(gè)子組件的屬性名一般為 child,多個(gè)子組件則是 children,因此,我們將代碼改成這樣:
Center(
child: Text("Hello World"),
),

如果想讓文字變成黑色,并且去掉下劃線呢?
Text 組件有一個(gè) style 屬性,我們只需要定義這個(gè)屬性,傳入一個(gè) TextStyle 對(duì)象,即可實(shí)現(xiàn)這一功能,代碼如下:
Center(
child: Text(
"Hello World",
style: TextStyle(
color: Colors.black,
decoration: TextDecoration.none,
),
),
),

你能想到的一切界面元素,都可以用類似的方式實(shí)現(xiàn),你也可以自己定義新的組件,例如為“透明度為 50%,顏色為淺藍(lán)色,顯示加號(hào)圖標(biāo)的按鈕”單獨(dú)定義一個(gè)組件,避免在代碼中重復(fù)設(shè)置這些參數(shù),我們?cè)诤笪囊矔?huì)定義自己的組件。
現(xiàn)在,你可以嘗試開(kāi)發(fā)自己的第一個(gè) Flutter App 了,而且是可以解決實(shí)際問(wèn)題的那種。
最基礎(chǔ)的頁(yè)面
創(chuàng)建項(xiàng)目
由于我已經(jīng)寫好了這個(gè)程序的源碼,下面的例子中,我將用 demo 這個(gè)項(xiàng)目進(jìn)行演示。
創(chuàng)建項(xiàng)目的過(guò)程在上文中已經(jīng)提到過(guò),這里不再贅述。為了避免項(xiàng)目中出現(xiàn)無(wú)用的文件夾,如果你使用的并不是 MacOS,創(chuàng)建項(xiàng)目時(shí)可以取消對(duì) MacOS 和 iOS 平臺(tái)的支持。
用你喜歡的編輯器打開(kāi)項(xiàng)目文件夾,找到 lib/main.dart 文件,運(yùn)行,應(yīng)該會(huì)看到和上文相同的計(jì)數(shù)器示例應(yīng)用。
main.dart 是應(yīng)用的入口,你的應(yīng)用會(huì)從這里開(kāi)始執(zhí)行。
導(dǎo)入依賴
官方示例代碼中包含大量的注釋,一共有一百多行,我們將 main.dart 刪除,重新創(chuàng)建一個(gè)同名文件,一切從頭開(kāi)始。
首先,我們需要導(dǎo)入依賴庫(kù),如果你安裝了上文中提到的代碼片段擴(kuò)展,輸入 importM 即可。
import 'package:flutter/material.dart';
后面,你還會(huì)用到更多的依賴庫(kù)。構(gòu)建發(fā)布包時(shí),第三方庫(kù)中沒(méi)有被使用到的代碼會(huì)被自動(dòng)裁剪,不必?fù)?dān)心應(yīng)用大小快速膨脹。
主函數(shù)
和 C、Java 等編程語(yǔ)言一樣,我們的程序需要一個(gè) main 函數(shù),它看起來(lái)是這樣的:
void main() {
runApp(const MyApp());
}
在這里,我們定義了一個(gè)無(wú)返回值的函數(shù),在里面調(diào)用了 runApp 函數(shù),這是前面導(dǎo)入的 Material 庫(kù)中的函數(shù),作用是運(yùn)行一個(gè) Flutter 應(yīng)用。
我們向這個(gè)函數(shù)傳入了一個(gè)參數(shù),它是一個(gè) MyApp 類的對(duì)象,并且是一個(gè)常量。
第一個(gè)組件
在你輸入這些代碼后,將會(huì)看到一個(gè)報(bào)錯(cuò),提示你 MyApp 未定義,我們來(lái)修復(fù)這個(gè)錯(cuò)誤。
這里,我們要?jiǎng)?chuàng)建一個(gè) Stateless Widget(無(wú)狀態(tài)組件),所謂無(wú)狀態(tài)組件,可以理解為只顯示內(nèi)容,不保存動(dòng)態(tài)數(shù)據(jù)的組件,這種組件會(huì)經(jīng)常被重新構(gòu)建。
如果你擔(dān)心頻繁構(gòu)建組件的性能,F(xiàn)lutter 的 Widget 樹(shù)和 Render 樹(shù)是分開(kāi)的,所以并不會(huì)導(dǎo)致頻繁重繪。
輸入 statelessW:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Container();
}
}
這段代碼可能有些難以理解,我們來(lái)逐行解釋。
第一行,我們創(chuàng)建了 MyApp 類,extends關(guān)鍵字表明后面的類是 MyApp 類的父類,我們做了一個(gè)繼承操作,讓 MyApp 類擁有 StatelessWidget 類的所有屬性和方法。
接下來(lái),我們定義了 MyApp 類的構(gòu)造函數(shù),大家對(duì) Dart 的語(yǔ)法還不太熟悉,這里定義了一個(gè)可選的參數(shù),名為 key,大家暫時(shí)不需要了解這個(gè)參數(shù)的作用。
由于這是一個(gè)無(wú)狀態(tài)組件,構(gòu)造函數(shù)返回的對(duì)象也是一個(gè)常量,因此我們?cè)跇?gòu)造函數(shù)前加上 const。
接下來(lái),我們覆寫(override)了 StatelessWidget 類的 build 方法,該方法用于構(gòu)建這個(gè) Widget。
組件的構(gòu)建可能會(huì)非常頻繁,這個(gè)方法的調(diào)用頻率很高,所以我們不能在里面做耗時(shí)的操作,否則會(huì)阻塞 UI 線程,造成應(yīng)用卡頓。
來(lái)點(diǎn)內(nèi)容
默認(rèn)的代碼片段返回了一個(gè) Container 對(duì)象,也就是一個(gè)容器。
在后續(xù)開(kāi)發(fā)中,
Container對(duì)象會(huì)經(jīng)常用于需要限制某個(gè)組件大小的場(chǎng)景。
由于 MyApp 是整個(gè)應(yīng)用程序的根 Widget,我們要用 MaterialApp 替換 Container,Material Design 是 Google 的設(shè)計(jì)語(yǔ)言,可以保證應(yīng)用的美觀簡(jiǎn)潔。
對(duì)于 iOS 風(fēng)的應(yīng)用,使用
CupertinoApp,但它擁有的參數(shù)和MaterialApp有一些不同,本文中不會(huì)涉及。
在 MaterialApp 對(duì)象中,指定我們應(yīng)用程序的主頁(yè),也就是 home 參數(shù)。
當(dāng)然,你可以在這里傳入一個(gè) Container,但更好的選擇是傳入一個(gè) Scaffold(腳手架)對(duì)象,它為我們的應(yīng)用程序提供了頂欄等基礎(chǔ)部件。
最后,在 Scaffold 對(duì)象中定義我們應(yīng)用程序的主體,也就是 body 參數(shù),傳入一個(gè) Center 對(duì)象,里面包裹一個(gè) Text 對(duì)象。
稍微修飾一下這個(gè)文本塊,使用 Text 對(duì)象的 style 屬性,設(shè)置 fontSize 參數(shù)為 36。
如果你使用 VS Code,代碼下面會(huì)出現(xiàn)一些藍(lán)色波浪線,這是因?yàn)槲覀儧](méi)有將靜態(tài)內(nèi)容聲明為常量,這一操作可以避免對(duì)象的重復(fù)構(gòu)建,在一定程度上提升應(yīng)用性能,你可以使用快速修復(fù)來(lái)解決這個(gè)問(wèn)題,或者直接在 return 后加入一個(gè) const。
完整代碼如下:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Text(
"Hello World",
style: TextStyle(
fontSize: 36,
),
),
),
),
);
}
}
運(yùn)行應(yīng)用:

設(shè)置頂欄
但應(yīng)用的頂欄呢?我們還沒(méi)有設(shè)置。在 Scaffold 組件中設(shè)置 appBar 屬性,傳入一個(gè) AppBar 對(duì)象。
這時(shí),會(huì)出現(xiàn)一些紅色波浪線,提示你現(xiàn)在 MaterialApp 不再是靜態(tài)對(duì)象了,需要去掉 const,你可以將 const 移到 body 參數(shù)的 Center 對(duì)象前。
在 AppBar 對(duì)象中,設(shè)置 title 參數(shù)為 Text 組件,填入文字“Flutter App”。
如果你不知道某個(gè)屬性對(duì)應(yīng)的數(shù)據(jù)類型,可以將鼠標(biāo)懸浮在屬性名上查看。
默認(rèn)情況下,標(biāo)題是靠左對(duì)齊的,所以我們一并傳入 centerTitle 參數(shù),值為 true。
另外,我們應(yīng)用程序的右上角還有一個(gè) Debug 標(biāo)識(shí),它只會(huì)在 Debug 模式下顯示,如果你希望去掉它,可以為 MaterialApp 組件傳入?yún)?shù) debugShowCheckedModeBanner,值為 false。
在上述過(guò)程中,你可以隨時(shí)使用 Ctrl + S 或者 Ctrl + F5 觸發(fā)熱重載,查看更改后的結(jié)果。
現(xiàn)在,你的應(yīng)用是這樣的:

完整代碼如下:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text("Flutter App"),
centerTitle: true,
),
body: const Center(
child: Text(
"Hello World",
style: TextStyle(
fontSize: 36,
),
),
),
),
debugShowCheckedModeBanner: false,
);
}
}
界面實(shí)現(xiàn)
接下來(lái),我們將著手完成應(yīng)用程序的界面部分。
我們的應(yīng)用標(biāo)題為“簡(jiǎn)書大轉(zhuǎn)盤中獎(jiǎng)列表”,應(yīng)用整體色調(diào)為紅色。
應(yīng)用的主體部分是一個(gè)列表,共有 100 項(xiàng),每項(xiàng)的內(nèi)容都是一個(gè)卡片,顯示一條中獎(jiǎng)記錄。
右下角有一個(gè)刷新按鈕,可以獲取最新的數(shù)據(jù)。
頂欄
首先是頂欄部分,很簡(jiǎn)單,修改 Text 組件的內(nèi)容即可。
應(yīng)用主題也很簡(jiǎn)單,向 MaterialApp 傳入一個(gè) theme 參數(shù),值為一個(gè) ThemeData 對(duì)象,設(shè)置該對(duì)象的 primarySwatch 屬性為 Colors.red。
Colors 對(duì)象中提供了很多顏色可供選擇。
這里,我們還可以做一件看起來(lái)很復(fù)雜,但在 Flutter 中很簡(jiǎn)單的事:為應(yīng)用適配深色模式。
只需要向 MaterialApp 傳入 darkTheme 參數(shù),同樣用 ThemeData 對(duì)象,將該對(duì)象的 brightness 參數(shù)設(shè)置為 Brightness.dark 即可。
最后設(shè)置 themeMode 為 ThemeMode.system,剩下的交給框架。
相關(guān)代碼如下:
theme: ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.red,
),
darkTheme: ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.red,
),
themeMode: ThemeMode.system,
現(xiàn)在,你可以將系統(tǒng)切換到夜間模式來(lái)查看效果,或者按照上文所述的方法打包安卓應(yīng)用,在手機(jī)上嘗試。
浮動(dòng)按鈕
Scaffold 組件中已經(jīng)提供了浮動(dòng)按鈕對(duì)應(yīng)的參數(shù),設(shè)置 floatingActionButton 參數(shù),傳入一個(gè) FloatingActionButton 對(duì)象,在 child 參數(shù)中傳入 Icon(Icons.refresh)。
這時(shí)會(huì)有報(bào)錯(cuò)出現(xiàn),因?yàn)槲覀儽仨殲榘粹o綁定點(diǎn)擊回調(diào),向 onPressed 參數(shù)傳入一個(gè)空函數(shù) () {} 即可,你可以使用 VS Code 的快速修復(fù)功能。
熱重載,可以在右下方看到浮動(dòng)按鈕,而且它也自動(dòng)使用了紅色作為主題色:

列表
接下來(lái)我們要實(shí)現(xiàn)應(yīng)用的主體部分:一個(gè)列表。
Flutter 中提供了兩種可以讓元素縱向排列的組件:Column 和 ListView。在 Column 組件中,超出顯示范圍(視口)的組件不會(huì)被自動(dòng)銷毀,而且不支持動(dòng)態(tài)構(gòu)建元素,因此在列表中的元素很多時(shí)會(huì)占用大量?jī)?nèi)存,并造成應(yīng)用卡頓。
因此,我們使用 ListView 作為應(yīng)用的主體部分。
將我們前面在 body 參數(shù)中傳入的 Center 組件替換成 ListView,設(shè)置其 children 參數(shù)為一個(gè)列表,包含三個(gè) Text 元素,其值分別為 1、2、3。

可以看到,數(shù)字聚集在左上角,可讀性不是很好,我們可以使用 Flutter 的 ListTile 組件解決這個(gè)問(wèn)題,它是一個(gè)通用的列表項(xiàng)組件。
將三個(gè) Text 組件換成三個(gè) ListTile 組件,分別設(shè)置三個(gè) ListTile 的 title 參數(shù),傳入對(duì)應(yīng)的 Text 組件,效果如下:

卡片
我們完全可以用這個(gè)組件進(jìn)行數(shù)據(jù)的展示,這也是我最初的設(shè)計(jì),但后來(lái)發(fā)現(xiàn)更好的選擇是 Card 組件。
這里我們解析一下簡(jiǎn)書的接口,最后篩選出需要展示的數(shù)據(jù)有這幾項(xiàng):
- 頭像
- 昵稱
- UID
- 獎(jiǎng)品名稱
- 獲獎(jiǎng)時(shí)間
將列表中的內(nèi)容換成一個(gè) Card 組件,設(shè)置其 child 為一個(gè) Container。
在容器中,我們使用 Column 進(jìn)行垂直布局,上下各放一個(gè) ListTile,上面顯示頭像、昵稱和 UID,下面顯示獎(jiǎng)品名稱和獲獎(jiǎng)時(shí)間。
結(jié)合上面學(xué)到的知識(shí),我們可以寫出如下代碼:
Card(
child: Container(
child: Column(
children: const [
ListTile(
title: Text("初心不變_葉子"),
subtitle: Text("UID:19867175"),
leading: CircleAvatar(
foregroundImage: NetworkImage(
"https://upload.jianshu.io/collections/images/1998647/crop1666796378205.jpg"
),
),
),
ListTile(
title: Text("獎(jiǎng)品名稱:收益加成卡1萬(wàn)\n獲獎(jiǎng)時(shí)間:2022-10-29 10:00:01"),
),
],
)
),
),
這里我們使用 leading 參數(shù)設(shè)置 ListTile 的頭部?jī)?nèi)容,CircleAvatar 是 Flutter 提供的圓形頭像組件,向它傳入一個(gè) ImageProvider 對(duì)象即可展示圓形頭像框,如果要考慮頭像加載失敗的問(wèn)題,可以設(shè)置 backgroundImage 或 backgroundColor。
Dart 中的換行與其它編程語(yǔ)言一樣,都是使用 \n 轉(zhuǎn)義符。
運(yùn)行效果如下所示:

(夾帶私貨)
很明顯,這個(gè) Card 還不夠好看,我們對(duì)它進(jìn)行一點(diǎn)優(yōu)化:
- 將昵稱的字體大小設(shè)為
20 - 將獎(jiǎng)品名稱和獲獎(jiǎng)時(shí)間的行高設(shè)為
1.6,可以通過(guò)TextStyle對(duì)象的height參數(shù)實(shí)現(xiàn) - 調(diào)整
Card組件的陰影值為3,通過(guò)Card組件的elevation參數(shù)實(shí)現(xiàn) - 為
Card組件加入適當(dāng)?shù)膱A角效果,設(shè)置Card組件的shape參數(shù),傳入一個(gè)RoundedRectangleBorder對(duì)象,設(shè)置其borderRadius參數(shù)為BorderRadius.circular(20) - 調(diào)整
Card組件的外邊距,設(shè)置Card組件的margin參數(shù),傳入EdgeInsets.symmetric,設(shè)置水平邊距為15,垂直邊距為7 - 用相同的方式設(shè)置
Container組件的內(nèi)邊距(padding),水平邊距為10,垂直邊距為15
熱重載,現(xiàn)在我們的應(yīng)用是這樣的:

相應(yīng)代碼如下:
Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 7),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
child: Column(
children: const [
ListTile(
title: Text(
"初心不變_葉子",
style: TextStyle(
fontSize: 20,
),
),
subtitle: Text("UID:19867175"),
leading: CircleAvatar(
foregroundImage: NetworkImage(
"https://upload.jianshu.io/collections/images/1998647/crop1666796378205.jpg"),
),
),
ListTile(
title: Text(
"獎(jiǎng)品名稱:收益加成卡1萬(wàn)\n獲獎(jiǎng)時(shí)間:2022-10-29 10:00:00",
style: TextStyle(
height: 1.6,
),
),
),
],
)),
),
組件封裝
在后文中,我們會(huì)大量用到這個(gè)組件,所以我們使用 StatelessWidget 類將它封裝成 RewardItem 組件,重寫它的構(gòu)造函數(shù),使其根據(jù)傳入?yún)?shù)顯示對(duì)應(yīng)內(nèi)容,代碼如下:
class RewardItem extends StatelessWidget {
final String userName;
final int uid;
final String avatarUrl;
final String rewardName;
final DateTime rewardTime;
const RewardItem({
super.key,
required this.userName,
required this.uid,
required this.avatarUrl,
required this.rewardName,
required this.rewardTime,
});
@override
Widget build(BuildContext context) {
final String timeString = rewardTime.toString().replaceRange(19, null, "");
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
margin: const EdgeInsets.symmetric(horizontal: 15, vertical: 7),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
child: Column(
children: [
ListTile(
title: Text(
userName,
style: const TextStyle(
fontSize: 20,
),
),
subtitle: Text("UID:$uid"),
leading: CircleAvatar(
foregroundImage: NetworkImage(avatarUrl),
),
),
ListTile(
title: Text(
"獎(jiǎng)品名稱:$rewardName\n獲獎(jiǎng)時(shí)間:$timeString",
style: const TextStyle(
height: 1.6,
),
),
),
],
)),
);
}
}
這里有幾點(diǎn)需要我們注意,首先,構(gòu)造函數(shù)前的 const 表示這是一個(gè)常量對(duì)象,所以它的所有屬性必須用 final 聲明為不可變。
在 Dart 中,文本內(nèi)可以使用 ${var_name} 插入變量,在僅取值,不做任何計(jì)算的情況下,大括號(hào)可以省略。
Dart 語(yǔ)言規(guī)范要求所有變量遵循小駝峰命名。
我不太了解怎么在 Dart 中指定時(shí)間字符串的格式,這里直接通過(guò)截?cái)嘧址鉀Q,反正能跑,管它呢。
接下來(lái),我們可以將 ListView 中 children 參數(shù)的列表清空,替換成以下內(nèi)容:
RewardItem(
userName: "初心不變_葉子",
uid: 19867175,
avatarUrl: "https://upload.jianshu.io/collections/images/1998647/crop1666796378205.jpg",
rewardName: "收益加成卡1萬(wàn)",
rewardTime: DateTime.now(),
),
熱重載,顯示不會(huì)發(fā)生變化(除了時(shí)間字段),但這樣的封裝可以幫助我們?cè)诖笮蛻?yīng)用程序中做到模塊的解耦,有助于提升可讀性。
從 API 獲取數(shù)據(jù)
我們要做一個(gè)數(shù)據(jù)展示 App,因此要解析簡(jiǎn)書的 API,拿到相應(yīng)的數(shù)據(jù)。
簡(jiǎn)書大轉(zhuǎn)盤中獎(jiǎng)列表的 API 如下:http://www.itdecent.cn/asimov/ad_rewards/winner_list。
請(qǐng)求方式是 GET,接收一個(gè) query arguments,名稱為 count,控制返回的數(shù)據(jù)量,這里我們將該參數(shù)設(shè)置為 100。
安裝依賴庫(kù)
Dart 有很多網(wǎng)絡(luò)請(qǐng)求庫(kù),我們這里使用的是 http 庫(kù)。
轉(zhuǎn)到 pubspec.yaml,在 dependencies 下加入以下內(nèi)容:
http:
保存文件,如果你使用 VS Code,插件將自動(dòng)幫你完成依賴下載,如果你需要手動(dòng)刷新依賴列表,請(qǐng)運(yùn)行以下命令:
flutter pub get
發(fā)起網(wǎng)絡(luò)請(qǐng)求
回到 main.dart,在文件開(kāi)頭導(dǎo)入這個(gè)庫(kù):
import 'package:http/http.dart' as http;
使用 as 關(guān)鍵字,可以為庫(kù)指定別名。
Dart 中有很多異步操作,網(wǎng)絡(luò)請(qǐng)求也不例外,所以我們編寫一個(gè)異步函數(shù),發(fā)起網(wǎng)絡(luò)請(qǐng)求并返回結(jié)果:
Future<List<dynamic>> getData() async {
var response = await http
.get(Uri.parse("http://www.itdecent.cn/asimov/ad_rewards/winner_list?count=100"));
List<dynamic> result = convert.jsonDecode(response.body);
return result;
}
在這里,我們要注意指定返回值的類型,返回值是一個(gè) List,內(nèi)部有一些鍵值對(duì),為了降低難度,我們使用 dynamic 將內(nèi)層聲明為動(dòng)態(tài)類型。
如果你需要確保函數(shù)編寫無(wú)誤,可以在 MyApp 的 build 方法開(kāi)頭插入以下代碼:
getData().then((value) => print(value));
熱重載,命令行中會(huì)輸出接口請(qǐng)求的結(jié)果。
根據(jù)數(shù)據(jù)更新 UI
由于 Widget 的構(gòu)建是同步操作,網(wǎng)絡(luò)請(qǐng)求是異步操作,我們需要一種機(jī)制,在數(shù)據(jù)可用時(shí)通知 UI 線程去更新內(nèi)容。Flutter 為我們提供了 FutureBuilder 組件,可以實(shí)現(xiàn)我們需要的功能。
在加入數(shù)據(jù)列表后,我們的應(yīng)用具有了狀態(tài),它的 Widget 類型也要從 StatelessWidget 變成 StatefulWidget。
輸入 statefulW,生成的代碼片段如下所示:
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return Container();
}
}
我們需要將原先 StatelessWidget 中的代碼拷貝到 _MyAppState 類中。
熱重載應(yīng)用,應(yīng)該不會(huì)看到任何變化。
接下來(lái),我們?cè)?_MyAppState 類中添加一個(gè)屬性 _dataList,聲明如下:
late Future<List<dynamic>> _listData;
late 關(guān)鍵字表示這個(gè)關(guān)鍵字不可空,并且我們將在稍后對(duì)它進(jìn)行賦值。
為了實(shí)現(xiàn)數(shù)據(jù)的動(dòng)態(tài)獲取與展示,我們需要使用 ListView 的 builder 構(gòu)造方法,它可以根據(jù) index 動(dòng)態(tài)構(gòu)建元素,這樣也可以避免單次構(gòu)建全部元素時(shí)造成的卡頓。
我們將 Scafflod 組件的 body 元素清空,改為一個(gè) FutureBuilder 對(duì)象。
向這個(gè)元素傳入 future 參數(shù),值為我們剛剛定義的 _listData 變量。
傳入 builder 參數(shù),該參數(shù)的值為一個(gè)函數(shù),我們將在這里完成解析邏輯。
這個(gè)函數(shù)的調(diào)用頻率也很高,不應(yīng)該在里面執(zhí)行密集計(jì)算邏輯,否則會(huì)影響列表構(gòu)建。
我們可以使用 snapshot.hasData 布爾值來(lái)判斷是否已經(jīng)獲取到數(shù)據(jù)。
CircularProgressIndicator 組件可以顯示一個(gè)圓形加載進(jìn)度條。
最終,我們的代碼如下:
body: FutureBuilder(
future: _listData,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else {
return ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
Map<String, dynamic> itemData = snapshot.data![index];
String userName = itemData["user"]["nickname"];
int uid = itemData["user"]["id"];
String avatarUrl = itemData["user"]["avatar"];
String rewardName = itemData["name"];
int rewardTimestamp = itemData["created_at"] * 1000;
DateTime rewardTime =
DateTime.fromMillisecondsSinceEpoch(rewardTimestamp);
return RewardItem(
userName: userName,
uid: uid,
avatarUrl: avatarUrl,
rewardName: rewardName,
rewardTime: rewardTime,
);
},
);
}
},
),
我們還需要重載 StatefulWidget 的一個(gè)初始化方法,在里面調(diào)用我們的異步函數(shù)進(jìn)行數(shù)據(jù)請(qǐng)求:
@override
void initState() {
super.initState();
_listData = getData();
}
這時(shí),需要使用熱重啟才能讓更改生效,在命令行中輸入 R,VS Code 中可以使用快捷鍵 Ctrl + Shift + F5。
你將會(huì)看到一個(gè)圓形的加載指示器,當(dāng)數(shù)據(jù)加載完成后,將顯示一個(gè)由卡片組成的列表。

如果你嘗試上下滑動(dòng),可能會(huì)感到卡頓,因?yàn)槲覀兊膽?yīng)用現(xiàn)在運(yùn)行在 debug 模式下,其代碼是使用 JIT(即時(shí)編譯)執(zhí)行的,并且開(kāi)啟了大量的調(diào)試功能。當(dāng)構(gòu)建 release 版本時(shí),所有代碼將被打包成對(duì)應(yīng)平臺(tái)的機(jī)器碼,運(yùn)行效率會(huì)大大提升。
編寫刷新按鈕回調(diào)
最后,我們還有一個(gè)問(wèn)題需要解決:現(xiàn)在右下角的刷新按鈕是無(wú)效的。
修改按鈕的回調(diào)函數(shù),使用 setState 方法更改組件狀態(tài),F(xiàn)lutter 將為你處理好其它邏輯。
同時(shí),我們希望給用戶一個(gè)明確的反饋,因此,我們彈出一個(gè) SnackBar,他將會(huì)顯示在頁(yè)面底部,同時(shí)我們希望它在顯示 2 秒后自動(dòng)消失。
使用 SnackBar 組件可以實(shí)現(xiàn)這一功能。
將以下代碼加入到按鈕回調(diào)中:
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("數(shù)據(jù)刷新成功!"),
duration: Duration(seconds: 2),
));
熱重載,點(diǎn)擊刷新按鈕,應(yīng)用報(bào)錯(cuò):
FlutterError (No ScaffoldMessenger widget found.
MyApp widgets require a ScaffoldMessenger widget ancestor.
The specific widget that could not find a ScaffoldMessenger ancestor was: MyApp
The ancestors of this widget were: [root]
Typically, the ScaffoldMessenger widget is introduced by the MaterialApp at the top of your application widget tree.)
抽離主頁(yè)組件
報(bào)錯(cuò)信息中提示我們,想要展示 SnackBar,必須要從這個(gè)組件的上層找到一個(gè) ScaffoldMessenger,由于我們的應(yīng)用中,MyApp 直接返回了一個(gè) MaterialApp,ScaffoldMessenger 是位于組件內(nèi)的,所以會(huì)產(chǎn)生異常。
剛好借這個(gè)機(jī)會(huì)提醒一下大家:即使是單頁(yè)應(yīng)用,也建議把頁(yè)面單獨(dú)抽離成一個(gè) Widget。
我們創(chuàng)建一個(gè)新的 StatelessWidget,名為 MyApp,在其中寫入以下代碼:
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const HomePage(),
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.red,
),
darkTheme: ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.red,
),
themeMode: ThemeMode.system,
);
}
}
同時(shí),修改之前的 MyApp 類為 HomePage,對(duì)應(yīng)的 State 類也需要更改。
刪除 HomePage 類關(guān)于 MaterialApp 的內(nèi)容,直接返回一個(gè) Scaffold 組件。
這里我們需要手動(dòng)重啟應(yīng)用,熱重載和熱重啟對(duì)這種修改都是無(wú)效的。
現(xiàn)在,點(diǎn)擊刷新按鈕,可以看到有新的數(shù)據(jù)出現(xiàn),同時(shí)頁(yè)面下方出現(xiàn) SnackBar。

至此,該應(yīng)用的所有功能已經(jīng)完成。
打包前的準(zhǔn)備工作
我們需要更改一下應(yīng)用的名稱,轉(zhuǎn)到 pubspec.yaml。
將第一行的 demo 改為你喜歡的應(yīng)用名稱,第二行的 description 也可以一并修改。
下方的 version 將影響不同平臺(tái)構(gòu)建產(chǎn)物的版本號(hào),切記只能增加不可減少。
X.Y.Z+A,X 代表主版本號(hào),Y 代表次版本號(hào),Z 代表修訂版本號(hào),A 是內(nèi)部的構(gòu)建版本號(hào)。
構(gòu)建可執(zhí)行文件
使用以下命令,構(gòu)建 Windows .exe 程序:
flutter build windows
構(gòu)建 Linux 二進(jìn)制文件:
flutter build linux
構(gòu)建 Android 通用 .apk 文件:
flutter build apk
構(gòu)建 Android 分包 .apk 文件:
flutter build apk --split-per-abi
打包 Web 應(yīng)用會(huì)遇到跨域請(qǐng)求問(wèn)題導(dǎo)致無(wú)限加載,這部分內(nèi)容超出了本文的范圍,此處不做展開(kāi)。
Linux 應(yīng)用程序位于 build/linux/x64/release/bundle 目錄下,大小約為 21.3MB。
Android 應(yīng)用程序位于 build/app/outputs/apk/release/ 目錄下,大小如下:
- 通用包:18.0MB
- 32 位:6.0MB
- 64 位:6.4MB
雖然 32 位包更小,但在應(yīng)用分發(fā)過(guò)程中,建議使用 64 位包,這樣可以獲得更好的性能,部分應(yīng)用商店也會(huì)強(qiáng)制要求 64 位包。
后記
這篇文章斷斷續(xù)續(xù)寫了一周,期間查了很多資料。
九千多字了,應(yīng)該是我寫過(guò)第二長(zhǎng)的文章,第一是一篇簡(jiǎn)書教程。
從環(huán)境配置到運(yùn)行示例應(yīng)用,介紹基礎(chǔ)概念,最后開(kāi)發(fā)一個(gè)具有實(shí)用價(jià)值的應(yīng)用,我認(rèn)為這是一篇完整的教程應(yīng)該做到的。
由于這是一個(gè)練手項(xiàng)目,我并沒(méi)有打算將代碼開(kāi)源到 GitHub 上,如果大家在學(xué)習(xí)過(guò)程中遇到任何問(wèn)題,或者想索要應(yīng)用的源代碼,歡迎與我簡(jiǎn)信聯(lián)系。
這個(gè)應(yīng)用我還會(huì)進(jìn)行一些迭代,等它被打磨的足夠完美,再開(kāi)始我的下一個(gè) Flutter 項(xiàng)目。
跨平臺(tái)不會(huì)取代原生,但它大大降低了應(yīng)用開(kāi)發(fā)的成本,讓獨(dú)立開(kāi)發(fā)者也可以輕松在不同設(shè)備上構(gòu)建一致的用戶體驗(yàn)。
Flutter 將作為我未來(lái)的技術(shù)發(fā)展方向之一。
Happy Coding, Happy Fluttering.