一、簡介
最後更新: 2022-03-14
FlutterFire 用於跨裝置通信
隨著我們見證大量家庭自動化、穿戴式和個人健康技術設備上線,跨裝置通訊成為建立行動應用程式日益重要的一部分。設定跨設備通信,例如透過手機應用程式控制瀏覽器,或透過手機控制電視上播放的內容,傳統上比建立普通的行動應用程式更複雜。
Firebase的即時資料庫提供了Presence API ,讓使用者可以查看他們的裝置線上/離線狀態;您將使用它與 Firebase Installations Service 一起追蹤和連接同一用戶登錄的所有設備。您將使用 Flutter 快速創建適用於多個平台的應用程序,然後構建一個可運行的跨設備原型在一台設備上播放音樂並控制另一台裝置上的音樂!
你將建構什麼
在此 Codelab 中,您將建立一個簡單的音樂播放器遙控器。您的應用程式將:
- 在 Android、iOS 和 Web 上擁有一個使用 Flutter 建立的簡單音樂播放器。
- 允許用戶登入。
- 當同一用戶在多個裝置上登入時連接設備。
- 允許使用者從一台裝置控制另一台裝置上的音樂播放。
你將學到什麼
- 如何建置和運行 Flutter 音樂播放器應用程式。
- 如何允許使用者使用 Firebase Auth 登入。
- 如何使用 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>
安裝它。
網路應用程式應顯示一個基本的獨立音樂播放器。確保播放器功能能如預期運作。這是為此 Codelab 設計的簡單音樂播放器應用。它只能播放 Firebase 歌曲Better Together 。
設定 Android 模擬器或 iOS 模擬器
如果您已有Android設備或iOS設備用於開發,則可以跳過此步驟。
若要建立 Android 模擬器,請下載也支援 Flutter 開發的Android Studio ,並依照建立和管理虛擬裝置中的說明進行操作。
要建立 iOS 模擬器,您需要 Mac 環境。下載XCode ,然後按照模擬器概述> 使用模擬器 >開啟和關閉模擬器中的說明進行操作。
3. 設定 Firebase
創建一個 Firebase 項目
開啟瀏覽器造訪http://console.firebase.google.com/ 。
- 登入Firebase 。
- 在 Firebase 控制台中,按一下新增專案(或建立專案),並將您的 Firebase 專案命名為Firebase-Cross-Device-Codelab 。
- 按一下項目建立選項。如果出現提示,請接受 Firebase 條款。跳過設定 Google Analytics,因為您不會為此應用程式使用 Analytics。
您不需要下載提到的檔案或更改 build.gradle 檔案。您將在初始化 FlutterFire 時配置它們。
安裝 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
安裝它。curl -sL https://firebase.tools | bash
。 - 透過運行
firebase login
並按照提示進行登入。 - 透過執行
dart pub global activate flutterfire_cli
安裝 FlutterFire CLI。 - 透過執行
flutterfire configure
配置 FlutterFire CLI。 - 根據提示,選擇您剛剛為此 Codelab 建立的項目,例如Firebase-Cross-Device-Codelab 。
- 當系統提示您選擇設定支援時,請選擇iOS 、 Android和Web 。
- 當系統提示輸入Apple 捆綁包 ID時,請輸入唯一的網域,或輸入
com.example.appname
,這對於本 Codelab 的目的來說是很好的。
配置完成後,將為您產生一個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) ;您將使用狀態 API 來追蹤設備在線/離線狀態
- Firebase 安全性規則將讓您保護資料庫。
- Firebase Installations 服務用於識別單一使用者已登入的裝置。
4. 新增 Firebase 身份驗證
啟用電子郵件登入以進行 Firebase 身份驗證
要允許使用者登入 Web 應用程序,您將使用電子郵件/密碼登入方法:
- 在 Firebase 控制台中,展開左側面板中的「建置」功能表。
- 按一下「驗證」 ,然後按一下「開始」按鈕,然後按一下「登入方法」標籤。
- 按一下登入提供者清單中的電子郵件/密碼,將啟用開關設定為開啟位置,然後按一下儲存。
在 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.
新增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 控制台的Authentication下,您應該可以看到列為新使用者的電子郵件地址。
恭喜!用戶現在可以登入該應用程式!
5.新增資料庫連接
現在您已準備好使用 Firebase Presence API 進行裝置註冊。
在命令列中,執行以下命令以新增必要的依賴項:
flutter pub add firebase_app_installations
flutter pub add firebase_database
建立資料庫
在 Firebase 控制台中,
- 導覽至Firebase 控制台的「即時資料庫」部分。按一下建立資料庫。
- 如果提示選擇安全規則的啟動模式,請立即選擇測試模式**。**(測試模式建立允許所有請求通過的安全規則。稍後您將添加安全規則。重要的是永遠不要將其投入生產您的安全規則仍處於測試模式。)
資料庫目前為空。在「專案設定」的「常規」標籤下找到您的databaseURL
。向下捲動到Web 應用程式部分。
將您的databaseURL
加入firebase_options.dart
檔案:
lib/firebase_options.dart
static const FirebaseOptions web = FirebaseOptions(
apiKey: yourApiKey,
...
databaseURL: 'https://<YOUR_DATABASE_URL>,
...
);
使用 RTDB Presence API 註冊設備
您希望在用戶的裝置出現在線上時對其進行註冊。為此,您將利用 Firebase 安裝和 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 中的 key 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 控制台中,導覽至即時資料庫,然後導覽至「規則」標籤。貼上以下規則,僅允許登入使用者讀取和寫入自己的裝置狀態:
{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid"
}
},
}
}
8. 恭喜!
恭喜您已經成功使用 Flutter 建置了跨裝置遠端控制器!
製作人員
更好在一起,Firebase 歌曲
- 瑞安‧弗農的音樂
- 瑪麗莎·克里斯蒂 (Marissa Christy) 作詞及專輯封面
- JP 戈麥斯 配音
9. 獎金
作為一項額外的挑戰,請考慮使用 Flutter FutureBuilder
將目前主導設備類型非同步新增至 UI。如果您需要協助,可以在包含 Codelab 完成狀態的資料夾中實作。