1. 简介
上次更新日期:2022 年 3 月 14 日
用于跨设备通信的 FlutterFire
随着大量家居自动化、穿戴式设备和个人健康技术设备上线,跨设备通信在构建移动应用时变得越来越重要。设置跨设备通信(例如通过手机应用控制浏览器,或通过手机控制电视上播放的内容)通常比构建普通移动应用更复杂。
Firebase 的 Realtime Database 提供了 Presence API ,可让用户查看其设备的在线/离线状态;您需要将其与 Firebase 安装服务搭配使用,以跟踪并连接同一用户已登录的所有设备。您将使用 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 构建应用,也可以使用命令行。
在应用目录中,使用以下命令构建 Web 应用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 的步骤,因为您不会对此应用使用 Google 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
进行安装。 - 运行
firebase login
并按照提示进行登录。 - 运行
dart pub global activate flutterfire_cli
以安装 FlutterFire CLI。 - 运行
flutterfire configure
以配置 FlutterFire CLI。 - 在提示符处,选择您刚刚为此 Codelab 创建的项目,例如 Firebase-Cross-Device-Codelab。
- 当系统提示您选择配置支持时,请选择 iOS、Android 和网页。
- 当系统提示您输入 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
您尚未更改任何界面元素,因此应用的外观和行为没有任何变化。不过,现在您已经拥有一个 Firebase 应用,可以开始使用 Firebase 产品,包括:
- Firebase Authentication:让用户可以登录到您的应用。
- Firebase Realtime Database (RTDB);您将使用 Presence API 跟踪设备在线/离线状态
- Firebase 安全规则可帮助您保护数据库。
- Firebase 安装服务:用于识别单个用户已登录的设备。
4. 添加 Firebase Auth
为 Firebase Authentication 启用电子邮件地址登录
如需允许用户登录 Web 应用,您将使用电子邮件地址/密码登录方法:
- 在 Firebase 控制台中,展开左侧面板中的 Build 菜单。
- 点击身份验证,然后依次点击开始使用按钮和登录方法标签页。
- 点击登录提供程序列表中的电子邮件地址/密码,将启用开关切换到开启位置,然后点击保存。
在 Flutter 中配置 Firebase Authentication
在命令行上,运行以下命令以安装必要的 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(),
));
}
同样,应用界面本应保持不变,但现在您可以允许用户登录并保存应用状态。
创建登录流程
在此步骤中,您将处理登录和退出流程。该流程如下所示:
- 已退出账号的用户可以点击应用栏右侧的上下文菜单 来启动登录流程。
- 登录流程将显示在对话框中。
- 如果用户之前从未登录过,系统会提示他们使用有效的电子邮件地址和密码创建账号。
- 如果用户之前已登录,系统会提示他们输入密码。
- 用户登录后,点击上下文菜单会显示一个退出选项。
添加登录流程需要完成以下三个步骤。
首先,创建一个 AppBarMenuButton
widget。此 widget 将根据用户的 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 控制台的 Authentication(身份验证)下,您应该会看到该电子邮件地址列为新用户。
恭喜!用户现在可以登录应用了!
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 安装和 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. 更新安全规则
除非我们编写更好的安全规则,否则有人可能会向他们不拥有的设备写入状态!因此,在完成之前,请更新 Realtime Database 安全规则,确保只有已登录该设备的用户才能对设备执行读取或写入操作。在 Firebase 控制台中,依次前往 Realtime Database 和 Rules(规则)标签页。粘贴以下规则,仅允许已登录的用户读取和写入自己的设备状态:
{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid"
}
},
}
}
8. 恭喜!
恭喜,您已成功使用 Flutter 构建了跨设备遥控器!
赠金
Firebase 歌曲《Better Together》
- 瑞恩·沃农的音乐
- 歌词和专辑封面由 Marissa Christy 提供
- 配音:JP Gomez
9. 奖金
另外,请考虑使用 Flutter FutureBuilder
将当前的主要设备类型异步添加到界面中。如果您需要帮助,请前往包含该代码实验室完成状态的文件夹。