一、简介
最后更新: 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 完成状态的文件夹中实现。