1. 簡介
上次更新時間:2022 年 3 月 14 日
使用 FlutterFire 進行跨裝置通訊
隨著大量居家自動化、穿戴式和個人健康技術裝置上線,跨裝置通訊在建構行動應用程式時變得越來越重要。設定跨裝置通訊 (例如透過手機應用程式控制瀏覽器,或透過手機控制電視上播放的內容) 比建構一般行動應用程式複雜。
Firebase 的即時資料庫提供狀態 API ,可讓使用者查看裝置的連線/離線狀態;您將搭配使用 Firebase Installations Service,追蹤並連結同一位使用者登入的所有裝置。您將使用 Flutter 快速建立多個平台適用的應用程式,然後建構跨裝置原型,在一部裝置上播放音樂,並在另一部裝置上控制音樂!
建構項目
在本程式碼研究室中,您將建構簡單的音樂播放器遙控器。您的應用程式將會:
- 在 Android、iOS 和網頁上使用 Flutter 建構簡易的音樂播放器。
- 允許使用者登入。
- 在多部裝置上登入同一位使用者時,連結這些裝置。
- 允許使用者透過其他裝置控制某部裝置的音樂播放。

課程內容
- 如何建構及執行 Flutter 音樂播放器應用程式。
- 如何允許使用者透過 Firebase 驗證登入。
- 瞭解如何使用 Firebase RTDB Presence API 和 Firebase 安裝服務連結裝置。
軟硬體需求
- Flutter 開發環境。按照 Flutter 安裝指南中的操作說明進行設定。
- Flutter 最低版本為 2.10。如果版本較舊,請執行
flutter upgrade. - Firebase 帳戶。
2. 開始設定
取得範例程式碼
我們已在 Flutter 中建立音樂播放器應用程式。範例程式碼位於 Git 存放區中。如要開始,請在指令列複製存放區,移至含有起始狀態的資料夾,然後安裝依附元件:
git clone https://github.com/FirebaseExtended/cross-device-controller.git
cd cross-device-controller/starter_code
flutter pub get
建構應用程式
您可以使用慣用的 IDE 建構應用程式,也可以使用指令列。
在應用程式目錄中,使用 flutter run -d web-server. 指令建構網頁版應用程式。您應該會看到下列提示。
lib/main.dart is being served at http://localhost:<port>
前往 http://localhost:<port> 即可查看音樂播放器。
如果您熟悉 Android 模擬器或 iOS 模擬器,可以為這些平台建構應用程式,並使用 flutter run -d <device_name> 指令安裝。
網頁應用程式應會顯示基本的獨立音樂播放器。確認播放器功能運作正常。這是專為本程式碼研究室設計的簡單音樂播放器應用程式。只能播放 Firebase 歌曲「Better Together」。
設定 Android 模擬器或 iOS 模擬器
如果您已有用於開發的 Android 裝置或 iOS 裝置,可以略過這個步驟。
如要建立 Android 模擬器,請下載 Android Studio (也支援 Flutter 開發),然後按照「建立及管理虛擬裝置」一文中的操作說明進行。
如要建立 iOS 模擬器,您需要 Mac 環境。下載 XCode,然後按照「模擬器總覽」>「使用模擬器」>「開啟及關閉模擬器」中的指示操作。
3. 設定 Firebase
建立 Firebase 專案
- 使用 Google 帳戶登入 Firebase 控制台。
- 按一下按鈕建立新專案,然後輸入專案名稱 (例如
Firebase-Cross-Device-Codelab)。
- 按一下「繼續」。
- 如果系統提示,請詳閱並接受 Firebase 條款,然後按一下「繼續」。
- (選用) 在 Firebase 控制台中啟用 AI 輔助功能 (稱為「Gemini in Firebase」)。
- 本程式碼研究室不需要 Google Analytics,因此請關閉 Google Analytics 選項。
- 按一下「建立專案」,等待專案佈建完成,然後按一下「繼續」。
安裝 Firebase SDK
回到指令列,在專案目錄中執行下列指令,安裝 Firebase:
flutter pub add firebase_core
在 pubspec.yaml 檔案中,將 firebase_core 的版本編輯為至少 1.13.1,或執行 flutter upgrade
初始化 FlutterFire
- 如果尚未安裝 Firebase CLI,請執行
curl -sL https://firebase.tools | bash安裝。 - 執行
firebase login並按照提示登入。 - 執行
dart pub global activate flutterfire_cli安裝 FlutterFire CLI。 - 執行
flutterfire configure設定 FlutterFire CLI。 - 在提示中,選擇您剛為本程式碼研究室建立的專案,例如「Firebase-Cross-Device-Codelab」。
- 系統提示選擇設定支援時,請選取「iOS」、「Android」和「網頁」。
- 系統提示輸入 Apple 組合 ID 時,請輸入不重複的網域,或輸入
com.example.appname,這在本程式碼研究室中是可接受的做法。
設定完成後,系統會為您產生 firebase_options.dart 檔案,其中包含初始化所需的所有選項。
在編輯器中,將下列程式碼新增至 main.dart 檔案,初始化 Flutter 和 Firebase:
lib/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyMusicBoxApp());
}
使用下列指令編譯應用程式:
flutter run
您尚未變更任何 UI 元素,因此應用程式的外觀和行為並未改變。但現在您已擁有 Firebase 應用程式,可以開始使用 Firebase 產品,包括:
- Firebase 驗證:可讓使用者登入您的應用程式。
- Firebase 即時資料庫(RTDB):您將使用 Presence API 追蹤裝置的連線/離線狀態
- Firebase 安全性規則可保護資料庫安全。
- Firebase Installations Service,用於識別單一使用者登入的裝置。
4. 新增 Firebase Auth
為 Firebase 驗證啟用電子郵件登入
如要允許使用者登入網頁應用程式,請使用「電子郵件/密碼」登入方式:
- 在 Firebase 控制台中,展開左側面板的「Build」選單。
- 依序點選「Authentication」和「Get Started」按鈕,然後點選「Sign-in method」分頁標籤。
- 在「Sign-in providers」清單中點選「Email/Password」,將「Enable」切換到開啟位置,然後點選「Save」。

在 Flutter 中設定 Firebase 驗證
在指令列中執行下列指令,安裝必要的 Flutter 套件:
flutter pub add firebase_auth
flutter pub add provider
完成這項設定後,您現在可以建立登入和登出流程。由於驗證狀態不應在不同畫面之間變更,因此您將建立 application_state.dart 類別,追蹤應用程式層級的狀態變更,例如登入和登出。如要進一步瞭解這項功能,請參閱 Flutter 狀態管理說明文件。
將下列內容貼到新的 application_state.dart 檔案中:
lib/src/application_state.dart
import 'package:firebase_auth/firebase_auth.dart'; // new
import 'package:firebase_core/firebase_core.dart'; // new
import 'package:flutter/material.dart';
import '../firebase_options.dart';
import 'authentication.dart';
class ApplicationState extends ChangeNotifier {
ApplicationState() {
init();
}
Future<void> init() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
} else {
_loginState = ApplicationLoginState.loggedOut;
}
notifyListeners();
});
}
ApplicationLoginState _loginState = ApplicationLoginState.loggedOut;
ApplicationLoginState get loginState => _loginState;
String? _email;
String? get email => _email;
void startLoginFlow() {
_loginState = ApplicationLoginState.emailAddress;
notifyListeners();
}
Future<void> verifyEmail(
String email,
void Function(FirebaseAuthException e) errorCallback,
) async {
try {
var methods =
await FirebaseAuth.instance.fetchSignInMethodsForEmail(email);
if (methods.contains('password')) {
_loginState = ApplicationLoginState.password;
} else {
_loginState = ApplicationLoginState.register;
}
_email = email;
notifyListeners();
} on FirebaseAuthException catch (e) {
errorCallback(e);
}
}
Future<void> signInWithEmailAndPassword(
String email,
String password,
void Function(FirebaseAuthException e) errorCallback,
) async {
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
} on FirebaseAuthException catch (e) {
errorCallback(e);
}
}
void cancelRegistration() {
_loginState = ApplicationLoginState.emailAddress;
notifyListeners();
}
Future<void> registerAccount(
String email,
String displayName,
String password,
void Function(FirebaseAuthException e) errorCallback) async {
try {
var credential = await FirebaseAuth.instance
.createUserWithEmailAndPassword(email: email, password: password);
await credential.user!.updateDisplayName(displayName);
} on FirebaseAuthException catch (e) {
errorCallback(e);
}
}
void signOut() {
FirebaseAuth.instance.signOut();
}
}
為確保應用程式啟動時會初始化 ApplicationState,請在 main.dart 中新增初始化步驟:
lib/main.dart
import 'src/application_state.dart';
import 'package:provider/provider.dart';
void main() async {
...
runApp(ChangeNotifierProvider(
create: (context) => ApplicationState(),
builder: (context, _) => const MyMusicBoxApp(),
));
}
同樣地,應用程式 UI 應維持不變,但現在您可以讓使用者登入及儲存應用程式狀態。
建立登入流程
在這個步驟中,您將處理登入和登出流程。流程如下:
- 登出的使用者只要按一下應用程式列右側的內容選單
,即可啟動登入流程。 - 登入流程會顯示在對話方塊中。
- 如果使用者從未登入,系統會提示他們使用有效電子郵件地址和密碼建立帳戶。
- 如果使用者先前已登入過,系統會提示他們輸入密碼。
- 使用者登入後,點按內容選單會顯示「登出」選項。

新增登入流程需要完成三個步驟。
首先,請建立 AppBarMenuButton 小工具。這個小工具會根據使用者的 loginState 控制內容選單彈出式視窗。新增匯入項目
lib/src/widgets.dart
import 'application_state.dart';
import 'package:provider/provider.dart';
import 'authentication.dart';
將下列程式碼附加至 widgets.dart.
lib/src/widgets.dart
class AppBarMenuButton extends StatelessWidget {
const AppBarMenuButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<ApplicationState>(
builder: (context, appState, child) {
if (appState.loginState == ApplicationLoginState.loggedIn) {
return SignedInMenuButton(buildContext: context);
}
return SignInMenuButton(buildContext: context);
},
);
}
}
class SignedInMenuButton extends StatelessWidget {
const SignedInMenuButton({Key? key, required this.buildContext})
: super(key: key);
final BuildContext buildContext;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
onSelected: _handleSignedInMenu,
color: Colors.deepPurple.shade300,
itemBuilder: (context) => _getMenuItemBuilder(),
);
}
List<PopupMenuEntry<String>> _getMenuItemBuilder() {
return [
const PopupMenuItem<String>(
value: 'Sign out',
child: Text(
'Sign out',
style: TextStyle(color: Colors.white),
),
)
];
}
Future<void> _handleSignedInMenu(String value) async {
switch (value) {
case 'Sign out':
Provider.of<ApplicationState>(buildContext, listen: false).signOut();
break;
}
}
}
class SignInMenuButton extends StatelessWidget {
const SignInMenuButton({Key? key, required this.buildContext})
: super(key: key);
final BuildContext buildContext;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
onSelected: _signIn,
color: Colors.deepPurple.shade300,
itemBuilder: (context) => _getMenuItemBuilder(context),
);
}
Future<void> _signIn(String value) async {
return showDialog<void>(
context: buildContext,
builder: (context) => const SignInDialog(),
);
}
List<PopupMenuEntry<String>> _getMenuItemBuilder(BuildContext context) {
return [
const PopupMenuItem<String>(
value: 'Sign in',
child: Text(
'Sign in',
style: TextStyle(color: Colors.white),
),
),
];
}
}
其次,在同一個 widgets.dart 類別中,建立 SignInDialog 小工具。
lib/src/widgets.dart
class SignInDialog extends AlertDialog {
const SignInDialog({Key? key}) : super(key: key);
@override
AlertDialog build(BuildContext context) {
return AlertDialog(
content: Column(mainAxisSize: MainAxisSize.min, children: [
Consumer<ApplicationState>(
builder: (context, appState, _) => Authentication(
email: appState.email,
loginState: appState.loginState,
startLoginFlow: appState.startLoginFlow,
verifyEmail: appState.verifyEmail,
signInWithEmailAndPassword: appState.signInWithEmailAndPassword,
cancelRegistration: appState.cancelRegistration,
registerAccount: appState.registerAccount,
signOut: appState.signOut,
),
),
]),
);
}
}
第三,在 main.dart. 中找出現有的 appBar 小工具,然後新增 AppBarMenuButton,即可顯示「登入」或「登出」選項。
lib/main.dart
import 'src/widgets.dart';
appBar: AppBar(
title: const Text('Music Box'),
backgroundColor: Colors.deepPurple.shade400,
actions: const <Widget>[
AppBarMenuButton(),
],
),
執行 flutter run 指令,重新啟動應用程式並套用這些變更。應用程式列右側應會顯示內容選單
。點選後會開啟登入對話方塊。
使用有效電子郵件地址和密碼登入後,內容選單中應該會顯示「登出」選項。
在 Firebase 控制台的「驗證」下方,您應該會看到電子郵件地址列為新使用者。

恭喜!使用者現在可以登入應用程式了!
5. 新增資料庫連線
現在您可以使用 Firebase Presence API 註冊裝置。
在指令列中執行下列指令,加入必要的依附元件:
flutter pub add firebase_app_installations
flutter pub add firebase_database
建立資料庫
在 Firebase 控制台中,
- 前往 Firebase 控制台的「Realtime Database」部分。按一下「建立資料庫」。
- 如果系統提示您選取安全性規則的啟動模式,請暫時選取「測試模式」**。** (測試模式會建立安全性規則,允許所有要求通過。您稍後會新增安全規則。請務必不要在安全性規則仍處於測試模式時,就將應用程式發布到正式環境。
資料庫目前是空的。在「專案設定」的「一般」分頁中,找出 databaseURL。向下捲動至「網頁應用程式」部分。

將 databaseURL 新增至 firebase_options.dart 檔案:
lib/firebase_options.dart
static const FirebaseOptions web = FirebaseOptions(
apiKey: yourApiKey,
...
databaseURL: 'https://<YOUR_DATABASE_URL>,
...
);
使用 RTDB Presence API 註冊裝置
您希望在使用者裝置上線時註冊裝置。如要達成這個目標,請善用 Firebase Installations 和 Firebase RTDB Presence API,追蹤單一使用者的線上裝置清單。下列程式碼有助於達成這個目標:
lib/src/application_state.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:firebase_app_installations/firebase_app_installations.dart';
class ApplicationState extends ChangeNotifier {
String? _deviceId;
String? _uid;
Future<void> init() async {
...
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
_uid = user.uid;
_addUserDevice();
}
...
});
}
Future<void> _addUserDevice() async {
_uid = FirebaseAuth.instance.currentUser?.uid;
String deviceType = _getDevicePlatform();
// Create two objects which we will write to the
// Realtime database when this device is offline or online
var isOfflineForDatabase = {
'type': deviceType,
'state': 'offline',
'last_changed': ServerValue.timestamp,
};
var isOnlineForDatabase = {
'type': deviceType,
'state': 'online',
'last_changed': ServerValue.timestamp,
};
var devicesRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/devices');
FirebaseInstallations.instance
.getId()
.then((id) => _deviceId = id)
.then((_) {
// Use the semi-persistent Firebase Installation Id to key devices
var deviceStatusRef = devicesRef.child('$_deviceId');
// RTDB Presence API
FirebaseDatabase.instance
.ref()
.child('.info/connected')
.onValue
.listen((data) {
if (data.snapshot.value == false) {
return;
}
deviceStatusRef.onDisconnect().set(isOfflineForDatabase).then((_) {
deviceStatusRef.set(isOnlineForDatabase);
});
});
});
}
String _getDevicePlatform() {
if (kIsWeb) {
return 'Web';
} else if (Platform.isIOS) {
return 'iOS';
} else if (Platform.isAndroid) {
return 'Android';
}
return 'Unknown';
}
回到指令列,在裝置上或瀏覽器中建構並執行應用程式 (使用 flutter run.)
在應用程式中,以使用者身分登入。請務必在不同平台上以相同使用者身分登入。
在 Firebase 控制台中,您應該會在資料庫中看到裝置顯示在一個使用者 ID 底下。

6. 同步處理裝置狀態
選取主要裝置
如要在裝置之間同步狀態,請將其中一個裝置指定為領導者或控制器。主裝置會決定追蹤裝置的狀態。
在 application_state.dart 中建立 setLeadDevice 方法,並在 RTDB 中使用 active_device 鍵追蹤這部裝置:
lib/src/application_state.dart
bool _isLeadDevice = false;
String? leadDeviceType;
Future<void> setLeadDevice() async {
if (_uid != null && _deviceId != null) {
var playerRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
await playerRef
.update({'id': _deviceId, 'type': _getDevicePlatform()}).then((_) {
_isLeadDevice = true;
});
}
}
如要在應用程式列內容選單中新增這項功能,請修改 SignedInMenuButton 小工具,建立名為 Controller 的 PopupMenuItem。使用者可透過這個選單設定主要裝置。
lib/src/widgets.dart
class SignedInMenuButton extends StatelessWidget {
const SignedInMenuButton({Key? key, required this.buildContext})
: super(key: key);
final BuildContext buildContext;
List<PopupMenuEntry<String>> _getMenuItemBuilder() {
return [
const PopupMenuItem<String>(
value: 'Sign out',
child: Text(
'Sign out',
style: TextStyle(color: Colors.white),
),
),
const PopupMenuItem<String>(
value: 'Controller',
child: Text(
'Set as controller',
style: TextStyle(color: Colors.white),
),
)
];
}
void _handleSignedInMenu(String value) async {
switch (value) {
...
case 'Controller':
Provider.of<ApplicationState>(buildContext, listen: false)
.setLeadDevice();
}
}
}
將主要裝置的狀態寫入資料庫
設定主要裝置後,您可以使用下列程式碼,將主要裝置的狀態同步至 RTDB。在 application_state.dart. 結尾附加下列程式碼,即可開始儲存兩項屬性:播放器狀態 (播放或暫停) 和滑桿位置。
lib/src/application_state.dart
Future<void> setLeadDeviceState(
int playerState, double sliderPosition) async {
if (_isLeadDevice && _uid != null && _deviceId != null) {
var leadDeviceStateRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
try {
var playerSnapshot = {
'id': _deviceId,
'state': playerState,
'type': _getDevicePlatform(),
'slider_position': sliderPosition
};
await leadDeviceStateRef.set(playerSnapshot);
} catch (e) {
throw Exception('updated playerState with error');
}
}
}
最後,每當控制器的播放器狀態更新時,您都需要呼叫 setActiveDeviceState。對現有的 player_widget.dart 檔案進行下列變更:
lib/player_widget.dart
import 'package:provider/provider.dart';
import 'application_state.dart';
void _onSliderChangeHandler(v) {
...
// update player state in RTDB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(_playerState.index, _sliderPosition);
}
Future<int> _pause() async {
...
// update DB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(_playerState.index, _sliderPosition);
return result;
}
Future<int> _play() async {
var result = 0;
// update DB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(PlayerState.PLAYING.index, _sliderPosition);
if (_playerState == PlayerState.PAUSED) {
result = await _audioPlayer.resume();
return result;
}
...
}
Future<int> _updatePositionAndSlider(Duration tempPosition) async {
...
// update DB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(_playerState.index, _sliderPosition);
return result;
}
從資料庫讀取主要裝置的狀態
如要讀取及使用主裝置的狀態,需要瞭解兩部分內容。首先,您要在 application_state 中設定主要播放器狀態的資料庫監聽器。這個監聽器會透過回呼,告知追蹤裝置何時更新畫面。請注意,您已在這個步驟中定義介面 OnLeadDeviceChangeCallback。目前尚未實作,您將在下一個步驟中於 player_widget.dart 實作這個介面。
lib/src/application_state.dart
// Interface to be implemented by PlayerWidget
typedef OnLeadDeviceChangeCallback = void Function(
Map<dynamic, dynamic> snapshot);
class ApplicationState extends ChangeNotifier {
...
OnLeadDeviceChangeCallback? onLeadDeviceChangeCallback;
Future<void> init() async {
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
_uid = user.uid;
_addUserDevice().then((_) => listenToLeadDeviceChange());
}
...
});
}
Future<void> listenToLeadDeviceChange() async {
if (_uid != null) {
var activeDeviceRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
activeDeviceRef.onValue.listen((event) {
final activeDeviceState = event.snapshot.value as Map<dynamic, dynamic>;
String activeDeviceKey = activeDeviceState['id'] as String;
_isLeadDevice = _deviceId == activeDeviceKey;
leadDeviceType = activeDeviceState['type'] as String;
if (!_isLeadDevice) {
onLeadDeviceChangeCallback?.call(activeDeviceState);
}
notifyListeners();
});
}
}
其次,在 player_widget.dart 中初始化播放器時,啟動資料庫監聽器。傳遞 _updatePlayer 函式,以便在資料庫值變更時更新追蹤者播放器狀態。
lib/player_widget.dart
class _PlayerWidgetState extends State<PlayerWidget> {
@override
void initState() {
...
Provider.of<ApplicationState>(context, listen: false)
.onLeadDeviceChangeCallback = updatePlayer;
}
void updatePlayer(Map<dynamic, dynamic> snapshot) {
_updatePlayer(snapshot['state'], snapshot['slider_position']);
}
void _updatePlayer(dynamic state, dynamic sliderPosition) {
if (state is int && sliderPosition is double) {
try {
_updateSlider(sliderPosition);
final PlayerState newState = PlayerState.values[state];
if (newState != _playerState) {
switch (newState) {
case PlayerState.PLAYING:
_play();
break;
case PlayerState.PAUSED:
_pause();
break;
case PlayerState.STOPPED:
case PlayerState.COMPLETED:
_stop();
break;
}
_playerState = newState;
}
} catch (e) {
if (kDebugMode) {
print('sync player failed');
}
}
}
}
現在可以測試應用程式了:
- 在指令列中,使用
flutter run -d <device-name>在模擬器和/或瀏覽器中執行應用程式。 - 在瀏覽器、iOS 模擬器或 Android 模擬器中開啟應用程式。前往內容選單,選擇一個應用程式做為領導裝置。當主控裝置更新時,你應該會看到追蹤裝置的播放器隨之變更。
- 現在變更領導者裝置、播放或暫停音樂,並觀察追隨者裝置是否相應更新。
如果追蹤裝置順利更新,表示您已成功建立跨裝置控制器。只差一個重要步驟。
7. 更新安全性規則
除非我們編寫更完善的安全性規則,否則使用者可能會將狀態寫入不屬於自己的裝置!因此在完成操作前,請更新即時資料庫安全規則,確保只有登入裝置的使用者可以讀取或寫入裝置。在 Firebase 控制台中,前往 Realtime Database,然後前往「規則」分頁標籤。貼上下列規則,只允許登入的使用者讀取及寫入自己的裝置狀態:
{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid"
}
},
}
}
8. 恭喜!

恭喜!您已使用 Flutter 成功建構跨裝置遙控器!
抵免額
Better Together (Firebase 之歌)
- 萊恩佛農的音樂
- 歌詞和專輯封面由 Marissa Christy 提供
- JP Gomez 配音
9. 獎金
不妨使用 Flutter FutureBuilder,以非同步方式將目前的領先裝置類型新增至 UI,增加挑戰性。如需輔助功能,請在包含程式碼研究室完成狀態的資料夾中實作。