Flutter 基础体系 · 第 79/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Firebase 工程:初始化、认证、消息、Crash 和环境隔离
Firebase 在 Flutter 工程中不是一个单独的 SDK,而是一组通过 Flutter 插件接入的云服务。firebase_core 负责创建 Firebase App;Authentication 使用这个 App 管理身份;Cloud Messaging(FCM)负责推送消息;Crashlytics 负责收集崩溃和非致命错误。它们共享“初始化完成”这个前置条件,但后续的生命周期、平台支持、权限模型和故障路径并不相同。
本文以当前稳定版 Flutter 与 Dart 3 为范围,示例覆盖 Android、iOS、Web,并明确说明桌面平台差异。示例中的包版本不固定为某个数字,因为 FlutterFire 插件会随 Flutter 和原生 Firebase SDK 演进;实际项目应使用当前兼容版本并提交 pubspec.lock。
一、先建立整体模型:一个 Firebase App,多个服务
Flutter 应用启动时通常经历以下数据流:
flowchart TD
A[Flutter main] --> B[Firebase.initializeApp]
B --> C[Firebase App]
C --> D[FirebaseAuth]
C --> E[FirebaseMessaging]
C --> F[FirebaseCrashlytics]
D --> G[用户身份状态]
E --> H[FCM Token]
E --> I[通知与数据消息]
F --> J[崩溃与非致命错误]
K[Firebase Project] --> C
K --> L[Authentication 配置]
K --> M[Cloud Messaging 配置]
K --> N[Crashlytics 配置]
这里有三个容易混淆的概念:
- Firebase Project:云端项目,例如
my-app-dev或my-app-prod。 - Firebase App:某个平台下注册在 Project 中的应用,例如 Android 包名为
com.example.app的应用。 - Flutter 插件对象:如
FirebaseAuth.instance、FirebaseMessaging.instance,它们通过默认 Firebase App 访问对应服务。
因此,“初始化 Firebase”不是登录,也不是获取 FCM Token,更不是打开 Crashlytics。初始化只是让插件获得项目标识、应用标识、API Key 等配置,从而能连接到对应的 Firebase Project。
1. 初始化必须先于服务访问
一个常见的启动约束是:
如果在初始化完成前调用:
FirebaseAuth.instance.authStateChanges()
或:
FirebaseMessaging.instance.getToken()
就可能得到 FirebaseException、平台通道尚未准备好的错误,或者在不同插件版本下表现为初始化异常。
Dart 的 main 可以是异步函数,因此应明确等待初始化:
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
每一步的作用是:
WidgetsFlutterBinding.ensureInitialized():在main中、运行 Flutter 框架相关异步或平台操作前,确保绑定已经创建。Firebase.initializeApp(...):根据当前平台加载 Firebase 配置并创建默认 App。await:保证runApp前初始化成功。runApp:只有在核心初始化完成后构建依赖 Firebase 的界面。
如果应用允许 Firebase 初始化失败后显示离线页面,应显式捕获异常,而不是继续假装服务可用:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
Object? firebaseError;
StackTrace? firebaseStack;
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
} catch (error, stack) {
firebaseError = error;
firebaseStack = stack;
}
runApp(
MyApp(
firebaseError: firebaseError,
firebaseStack: firebaseStack,
),
);
}
生产取舍在于:认证、消息和 Crashlytics 都不能正常工作时,通常应显示“服务不可用”或降级页面;但不能在 Firebase 初始化失败后继续调用 FirebaseAuth.instance 并把后续异常误判为业务错误。
二、配置来源:FlutterFire CLI、原生配置和 Web 配置
1. 添加插件
一个最小工程通常需要:
flutter pub add firebase_core
flutter pub add firebase_auth
flutter pub add firebase_messaging
flutter pub add firebase_crashlytics
这些命令会修改 pubspec.yaml,然后解析依赖:
flutter pub get
安装 Firebase CLI 和 FlutterFire CLI 后,可以从 Firebase Project 生成 Flutter 配置:
firebase login
dart pub global activate flutterfire_cli
flutterfire configure
flutterfire configure 的实际作用不是“创建一个服务器”,而是:
- 选择 Firebase Project;
- 选择 Android、iOS、Web 等平台;
- 注册或关联平台应用;
- 生成
lib/firebase_options.dart; - 对 Android 和 iOS 写入相应的原生配置文件。
生成的代码通常包含:
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
// 根据 TargetPlatform 返回 Android、iOS、Web 等配置
}
}
工程中应使用生成文件中的配置,而不是手写项目 ID、API Key 和应用 ID。生成文件不是服务账号凭据,里面的 Web API Key 也不能作为服务端密钥使用;真正的服务账号私钥、FCM 服务端凭据等绝不能放入 Flutter 客户端。
2. Android、iOS 和 Web 的配置差异
Android
Android 通常使用:
android/app/google-services.json
Gradle 的 Google Services 插件会根据包名找到对应 Firebase Android App。包名必须与 Firebase 控制台中注册的 Android 应用一致。例如:
applicationId "com.example.app"
如果 Firebase 中注册的是 com.example.app.dev,而构建产物实际包名是 com.example.app,就会出现配置不匹配。常见表现包括:
No matching client found for package name- Firebase 初始化失败;
- FCM Token 获取失败;
- Google 登录或其他依赖 SHA-1/SHA-256 的服务配置不生效。
调试和发布签名的 SHA-1、SHA-256 也可能不同。需要将实际使用的签名证书指纹添加到对应 Firebase Android App,特别是使用 Google 登录时。
iOS
iOS 通常使用:
ios/Runner/GoogleService-Info.plist
Firebase 中注册的 Bundle ID 必须与 Xcode 的实际 Bundle Identifier 一致。不同 Scheme 或 Build Configuration 可以对应不同 plist,但不能只改变 Dart 常量而继续使用生产 plist。
FCM 在 iOS 上还依赖 Apple Push Notification service(APNs)。因此,Firebase 配置正确不等于推送一定可用,还需要:
- 在 Apple Developer 中启用 Push Notifications;
- 在 Xcode 的 Signing & Capabilities 中启用 Push Notifications;
- 配置 Background Modes 中的 Remote notifications(当业务需要后台数据消息时);
- 在 Firebase 中配置 APNs Authentication Key 或证书;
- 真机测试,因为模拟器对推送能力存在平台和版本限制,不能将模拟器结果当作完整验证。
Web
Web 使用 FirebaseOptions 中的 Web 配置,消息还需要浏览器 Service Worker。对于 FCM Web,通常需要在 Web 根目录准备 firebase-messaging-sw.js,并根据当前 Firebase Web SDK 版本使用兼容的 Service Worker 配置。
Web 推送通常还需要 VAPID 公钥:
final token = await FirebaseMessaging.instance.getToken(
vapidKey: '从 Firebase 控制台 Web Push certificates 获取的公钥',
);
这里的 VAPID 公钥可以出现在前端;对应的私钥不能放入前端。Web 推送必须在安全上下文中运行,通常是 HTTPS,localhost 是开发例外。
桌面平台
Android、iOS 和 Web 是 Firebase Flutter 插件最常见、支持最完整的目标。Windows 和 Linux 通常不能直接使用 firebase_messaging 或 firebase_crashlytics 的完整原生能力;macOS 的支持情况也必须以相应 FlutterFire 插件版本的官方平台支持声明为准,不能因为“Firebase 原生 SDK 支持 macOS”就推断 Flutter 插件所有能力都支持 macOS。
因此桌面工程通常采用以下策略之一:
- 桌面只使用
firebase_core、部分认证能力或 Web SDK 方案; - 消息改由自有桌面通知通道实现;
- 崩溃采集改用桌面专用监控 SDK;
- 在
pubspec和代码中明确做平台分支,而不是运行时等待一个永远不会成功的插件调用。
三、初始化生命周期与多环境配置
1. 默认 App 和命名 App
最简单的项目只有一个默认 App:
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
初始化后,插件通过默认 App 工作:
final auth = FirebaseAuth.instance;
final messaging = FirebaseMessaging.instance;
Firebase 也支持命名 App:
final secondaryApp = await Firebase.initializeApp(
name: 'secondary',
options: const FirebaseOptions(
apiKey: '...',
appId: '...',
messagingSenderId: '...',
projectId: '...',
),
);
但 FlutterFire 的各服务插件是否支持通过命名 App 构造实例、以及不同平台行为,需要查看对应插件 API。常规业务环境隔离不应依赖“同一个进程同时连接开发和生产”来实现,而应使用不同构建环境和不同 Firebase Project。
2. 环境隔离的核心条件
环境隔离不是把一个字符串命名为 dev,而是确保以下映射一致:
其中 是环境,如 dev、staging 或 prod。如果其中一个元素仍指向另一个环境,就会产生“半隔离”:
- Debug 包连接生产 Firebase;
- 生产包连接测试认证服务;
- Dart 显示开发环境名称,但 FCM Token 属于生产项目;
- Crashlytics 的崩溃混入错误项目;
- 测试用户进入真实生产数据库。
推荐的项目结构是:
Firebase Project: my-app-dev
Android App: com.example.app.dev
iOS App: com.example.app.dev
Web App: dev web app
Firebase Project: my-app-prod
Android App: com.example.app
iOS App: com.example.app
Web App: prod web app
开发和生产使用不同 Firebase Project,而不是仅使用同一项目中的不同数据库集合。不同 Project 可以隔离:
- Authentication 用户;
- Firestore/Realtime Database 数据;
- Storage 文件;
- FCM 发送目标;
- Crashlytics 报告;
- 安全规则和配额。
3. Flutter flavor 与配置文件
Android 可以通过 product flavor 选择不同原生配置,例如:
android/app/src/dev/google-services.json
android/app/src/prod/google-services.json
iOS 则可以通过 Scheme、Build Configuration 和对应的 plist 选择配置。Dart 代码通常通过 --dart-define 得到环境标识:
flutter run \
--flavor dev \
--dart-define=APP_ENV=dev
读取方式:
const appEnv = String.fromEnvironment(
'APP_ENV',
defaultValue: 'dev',
);
但 String.fromEnvironment 是编译期常量读取,不是运行时配置系统。它不能自动替换 Android 的 google-services.json 或 iOS 的 plist。正确的关系是:
- flavor 决定原生构建配置;
--dart-define决定 Dart 层环境标识;firebase_options.dart或原生文件必须与前两者指向同一个 Firebase Project;- CI 在构建时验证三者一致。
使用 flutterfire configure 时,可以按环境分别生成配置文件,例如将开发和生产配置生成到不同 Dart 文件,再由构建入口选择。若直接覆盖同一个 firebase_options.dart,很容易在本地切换环境后忘记恢复生产配置。
4. 不要把配置文件当成安全边界
Firebase 客户端配置中的:
- API Key;
- Project ID;
- App ID;
- Messaging Sender ID;
通常不是服务端秘密。客户端必须知道它们才能连接 Firebase。
真正需要保护的是:
- Firebase Admin SDK 服务账号私钥;
- FCM 服务端发送凭据;
- 数据库管理密钥;
- 第三方支付或后端服务密钥。
即使客户端配置公开,数据安全仍依赖 Authentication、Firestore/Storage Security Rules、App Check 和后端授权。认证只回答“你是谁”,不自动回答“你能读写什么”。
四、Firebase Authentication:从身份状态到授权决策
1. Authentication 的状态模型
Firebase Authentication 是身份认证服务。它维护当前设备上的 Firebase 用户会话,并提供:
- 注册和登录;
- 登出;
- 身份凭据刷新;
- 身份状态流;
- ID Token 获取。
Flutter 中的 User? 表示当前 Firebase 用户:
null:当前没有已认证用户;- 非空
User:存在当前认证用户。
监听登录状态:
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart';
class AuthRepository {
final FirebaseAuth _auth = FirebaseAuth.instance;
Stream<User?> get authStateChanges => _auth.authStateChanges();
Future<UserCredential> signIn(
String email,
String password,
) {
return _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
}
Future<void> signOut() {
return _auth.signOut();
}
}
UI 可以根据流构建登录页或主页:
StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SplashPage();
}
final user = snapshot.data;
if (user == null) {
return const LoginPage();
}
return HomePage(user: user);
},
)
这里必须区分三个状态:
waiting:Firebase 还在恢复本地会话;active + data == null:确认没有登录用户;active + data != null:确认存在登录用户。
如果直接在 waiting 时显示登录页,应用启动时可能短暂闪现登录页面,然后又跳转到主页。
2. 三种用户状态流并不等价
FlutterFire 常见的三个流语义不同:
authStateChanges():登录或登出时触发;初始化时也会触发一次。idTokenChanges():登录、登出以及 ID Token 刷新时触发。userChanges():用户对象发生更广泛变化时触发,例如更新密码、邮箱、个人资料等。
如果界面只关心“是否登录”,使用 authStateChanges() 足够。如果界面依赖自定义 Claims 或 Token 刷新后的身份信息,应该考虑 idTokenChanges(),但还必须处理服务端 Claims 更新的传播延迟。
3. 认证错误不能按字符串判断
登录代码应区分常见失败原因:
Future<String?> signIn(
String email,
String password,
) async {
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
return null;
} on FirebaseAuthException catch (error) {
switch (error.code) {
case 'invalid-credential':
case 'user-not-found':
case 'wrong-password':
return '邮箱或密码不正确';
case 'invalid-email':
return '邮箱格式不正确';
case 'user-disabled':
return '该账号已被禁用';
case 'too-many-requests':
return '尝试次数过多,请稍后再试';
default:
return '登录失败,请稍后重试';
}
}
}
需要注意,Firebase Auth 错误码可能随插件和后端策略变化,不能把所有版本差异都当作稳定业务协议。对用户展示应使用自己的文案,对日志保留 error.code 和上下文,但不要记录密码、完整 Token 或敏感凭据。
4. 身份认证不等于数据库授权
设用户 A 登录成功,只能推出:
不能推出:
Firestore 规则需要继续限制资源。例如只允许用户访问自己的文档:
match /users/{userId} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
这条规则中的因果关系是:
- 客户端发送请求;
- Firebase Authentication 验证请求中的身份;
- Security Rules 读取
request.auth.uid; - 只有路径中的
userId与当前用户 UID 相等时才允许访问。
客户端隐藏按钮不是授权措施,因为攻击者可以绕过 Flutter UI 直接调用 Firebase API。
五、Firebase Cloud Messaging:权限、Token、消息和生命周期
FCM 是消息传递系统。它至少涉及四个对象:
- 发送方:后端、Firebase Console 或 Cloud Functions;
- 项目:决定消息属于哪个 Firebase Project;
- 注册 Token:代表某个应用安装实例;
- 接收处理器:前台、后台或被系统终止时的处理路径。
FCM Token 不是用户 ID。卸载重装、清除数据、恢复设备或 Firebase 重新生成 Token 后,Token 都可能变化。因此后端应允许 Token 更新和失效清理。
1. 请求权限
Android 13 及以上需要运行时通知权限;iOS 也需要用户授权;Web 需要浏览器通知权限。Flutter 代码可以统一调用:
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
// 可以继续获取 Token
}
权限状态不是业务登录状态。用户拒绝通知时,仍然可以使用 Firebase Authentication。
iOS 上获取 Token 前,有时需要等待 APNs Token:
if (defaultTargetPlatform == TargetPlatform.iOS) {
String? apnsToken;
for (var i = 0; i < 10 && apnsToken == null; i++) {
apnsToken = await messaging.getAPNSToken();
if (apnsToken == null) {
await Future<void>.delayed(const Duration(milliseconds: 300));
}
}
}
这段等待只是规避 APNs Token 尚未准备好的时序问题,不是所有环境都必须等待固定次数。真正的生产代码还应处理超时,并记录设备、系统和权限状态。
2. 获取和更新 FCM Token
Future<void> registerMessaging() async {
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
final token = await messaging.getToken();
if (token != null) {
await uploadTokenToBackend(token);
}
FirebaseMessaging.instance.onTokenRefresh.listen((newToken) async {
await uploadTokenToBackend(newToken);
});
}
Future<void> uploadTokenToBackend(String token) async {
// 通过已认证的 HTTPS API 上传,而不是直接信任客户端传入的用户 ID。
}
后端保存 Token 时,应把 Token 与当前认证用户、设备信息和最后更新时间关联。更可靠的模型是:
userId
└── installationId
├── fcmToken
├── platform
└── updatedAt
登出时删除或解绑 Token,至少不要继续把登录前设备的 Token 当成当前用户的稳定身份。若一个设备允许多账号切换,Token 与用户的绑定关系必须重新计算。
3. 前台消息、点击消息和后台消息
注册前台监听:
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final notification = message.notification;
final data = message.data;
debugPrint('foreground message id=${message.messageId}');
debugPrint('title=${notification?.title}');
debugPrint('data=$data');
});
点击通知进入应用时:
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
handleMessageNavigation(message);
});
final initialMessage =
await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
handleMessageNavigation(initialMessage);
}
这两个入口分别覆盖:
- 应用在后台,用户点击通知后回到应用;
- 应用已被系统终止,用户通过通知冷启动应用。
后台处理器必须是顶层函数,不能是实例方法或闭包:
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
RemoteMessage message,
) async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
debugPrint('background message id=${message.messageId}');
}
注册方式:
FirebaseMessaging.onBackgroundMessage(
firebaseMessagingBackgroundHandler,
);
原因有两个:
- 后台处理可能运行在独立的 Dart isolate;
- isolate 不共享主 isolate 的内存状态、依赖注入容器和已创建对象。
因此后台处理器中不能假设主 isolate 已初始化某个单例,也不应直接操作 Flutter UI。后台任务受操作系统时间和资源限制,适合轻量处理;如果必须执行可靠的长任务,应将任务转交后端或使用平台专用后台机制。
4. Notification 消息和 Data 消息
FCM 常见消息载荷可以抽象为:
{
"notification": {
"title": "订单更新",
"body": "订单已发货"
},
"data": {
"orderId": "123"
}
}
notification 部分通常可由系统在后台显示;data 部分由应用读取。前台、后台、被终止时的行为不同,并且 Android、iOS、Web 也不同,不能假设同一载荷在所有状态下都由 Dart 代码完整接管。
一个更安全的点击处理方式是只把数据解析为导航意图:
void handleMessageNavigation(RemoteMessage message) {
final orderId = message.data['orderId'];
if (orderId is String && orderId.isNotEmpty) {
// 将导航意图放入应用状态,等待 Navigator 就绪后再跳转。
}
}
不要直接相信通知数据中的价格、权限或支付结果。通知是客户端可见输入,真正的订单状态必须由后端查询确认。
5. 消息失败的诊断顺序
当“能拿到 Token 但收不到消息”时,应按路径逐段验证:
- 确认构建包连接了正确 Firebase Project;
- 确认 Token 来自当前安装实例且未过期;
- 确认 Android 13+ 或 iOS/Web 权限已授权;
- iOS 确认 APNs 配置和真机能力;
- 确认 Android 通知渠道、系统通知开关和厂商后台限制;
- 确认发送目标是 Token、Topic 还是用户条件;
- 确认前台、后台、终止态使用了正确监听入口;
- 检查后端发送响应和无效 Token;
- 通过
messageId、平台、版本、环境记录端到端日志。
仅在 Firebase Console 点击“发送测试消息”并看到服务端发送成功,不代表应用一定会显示通知;这只证明发送方接受了请求。
六、Crashlytics:崩溃采集、非致命错误和验证
Crashlytics 是崩溃与错误报告系统,核心价值在于把异常、堆栈、设备环境和应用版本聚合起来。它不是一个通用日志数据库,也不保证捕获所有进程终止。
Crashlytics 的 Flutter 支持重点是 Android 和 Apple 平台。Web 通常不提供与移动端等价的 Crashlytics 能力;Windows、Linux 等桌面平台也不能假设有完整支持。桌面和 Web 应根据平台选择其他错误监控方案。
1. 绑定 Flutter 和平台错误入口
初始化完成后,可以设置 Flutter 框架错误处理器:
import 'dart:ui';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
void installCrashReporting() {
FlutterError.onError = (FlutterErrorDetails details) {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(
error,
stack,
fatal: true,
);
return true;
};
}
启动顺序:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
installCrashReporting();
FirebaseMessaging.onBackgroundMessage(
firebaseMessagingBackgroundHandler,
);
runApp(const MyApp());
}
这里有两个不同入口:
FlutterError.onError:捕获 Flutter 框架报告的错误,例如布局、构建或绘制阶段错误;PlatformDispatcher.instance.onError:捕获未处理的异步平台级错误。
这两个入口不能互相替代。业务代码中的已处理异常应使用非致命记录:
Future<void> loadProfile() async {
try {
await fetchProfile();
} catch (error, stack) {
await FirebaseCrashlytics.instance.recordError(
error,
stack,
reason: 'load profile failed',
fatal: false,
);
rethrow;
}
}
recordError 的作用是记录错误,不是自动恢复业务。是否 rethrow 取决于调用层是否能够展示降级状态。吞掉异常会让应用看似稳定,却可能留下错误数据。
2. 添加可搜索上下文
Crashlytics 需要上下文才能区分“同一个异常发生在谁、哪一版本、哪条业务路径”:
final crashlytics = FirebaseCrashlytics.instance;
await crashlytics.setUserIdentifier(user.uid);
await crashlytics.setCustomKey('app_env', appEnv);
await crashlytics.setCustomKey('build_flavor', 'prod');
crashlytics.log('开始打开订单详情');
不要记录:
- 密码;
- ID Token、Refresh Token;
- 身份证号、完整手机号等不必要的个人信息;
- 支付卡号;
- 完整请求头或服务端密钥。
setUserIdentifier 也应使用内部用户 ID 或不可逆标识,是否允许使用真实业务 ID 取决于隐私政策和数据治理要求。
3. 强制崩溃与验证
可以用测试按钮验证 Crashlytics:
ElevatedButton(
onPressed: () {
FirebaseCrashlytics.instance.crash();
},
child: const Text('测试崩溃'),
)
验证时必须考虑:
- 使用真实设备或受支持平台;
- 使用 Debug、Profile、Release 中与项目配置一致的构建;
- 崩溃后重新启动应用,因为报告通常需要在下一次启动时上传;
- 等待控制台处理符号化和聚合;
- 确认报告位于正确 Firebase Project,而不是只确认“有一条崩溃”。
生产代码不应暴露此按钮。更安全的方式是仅在开发构建中编译:
import 'package:flutter/foundation.dart';
bool get allowCrashTest => kDebugMode;
还要区分“进程被系统杀死”和“应用发生未捕获异常”。内存不足、操作系统强杀、断电、设备重启等情况不一定能被 Crashlytics 捕获,因此 Crashlytics 数据不能被当作完整的进程退出审计。
七、把初始化、认证、消息和 Crash 组合成可测试的启动器
直接在 main 中堆叠全局副作用,短期简单,长期难以测试。可以将初始化拆成有顺序的步骤:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'firebase_options.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(
RemoteMessage message,
) async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
}
Future<void> bootstrap() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
if (!kIsWeb) {
FlutterError.onError = (details) {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
FirebaseCrashlytics.instance.recordError(
error,
stack,
fatal: true,
);
return true;
};
}
if (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) {
FirebaseMessaging.onBackgroundMessage(
firebaseMessagingBackgroundHandler,
);
}
}
Future<void> main() async {
await bootstrap();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
这段代码体现了四个重要边界:
- Firebase 核心初始化只有一次;
- Crashlytics 只在受支持的平台启用;
- 后台消息处理器使用顶层函数;
- UI 在核心启动流程完成后创建。
实际工程还应把消息权限、Token 上传、用户绑定、路由处理放在明确的生命周期位置,而不是在任意 Widget 的 build 中执行。build 可能被调用多次,在其中注册监听会导致重复订阅、重复导航或重复上传 Token。
八、典型失败路径与诊断方法
1. Firebase has not been correctly initialized
可能原因:
- 忘记调用
Firebase.initializeApp; - 没有
await初始化; firebase_options.dart未生成或平台配置缺失;- Android 包名、iOS Bundle ID 与 Firebase 注册信息不匹配;
- 当前运行平台没有对应配置。
验证方法:
debugPrint(Firebase.apps.map((app) => app.name).join(', '));
debugPrint(DefaultFirebaseOptions.currentPlatform.projectId);
应确认:
- 默认 App 已存在;
- Project ID 是当前环境;
- 原生应用标识与构建产物一致。
不要通过反复调用初始化“碰运气”修复问题。如果确实存在多入口初始化,应统一由启动器负责,并避免多个地方竞争创建默认 App。
2. Crashlytics 有数据,但归属错误环境
这通常不是 Crashlytics 聚合错误,而是配置映射错误。检查:
- Release 构建使用的
google-services.json或 plist; firebase_options.dart的 Project ID;- Android flavor;
- iOS Scheme;
- CI 是否缓存了旧配置;
- 应用包名是否与预期环境一致。
可以在 Crashlytics 自定义键中加入环境,但这只能帮助诊断,不能替代配置隔离。
3. FCM Token 存在但点击不跳转
可能原因:
- 只监听了
onMessage,没有监听onMessageOpenedApp; - 没有调用
getInitialMessage; - 应用尚未完成路由初始化就执行跳转;
data中缺少合法业务 ID;- 通知由系统展示,但应用没有按预期收到前台回调;
- 把 Android 行为错误地套用到 iOS 或 Web。
应把“消息接收”和“业务导航”分开:先将消息转换为内部事件,再由已经就绪的导航层消费事件。这样可以避免冷启动时 Navigator 尚未创建的问题。
4. 登录成功但数据库访问被拒绝
这通常是 Security Rules 或当前 Token 状态问题,不是“Firebase Auth 没登录”。检查:
- 当前
FirebaseAuth.instance.currentUser?.uid; - 规则是否使用相同 UID;
- 是否需要刷新 ID Token;
- 是否连接了正确项目;
- 本地用户是否属于开发项目而数据位于生产项目;
- 服务端时间、规则发布状态和 Emulator 配置。
认证、身份声明、数据库规则和后端授权应分别验证,不能只检查登录页面是否显示成功。
九、模拟器、Firebase Emulator Suite 与生产验证
开发阶段可以使用 Firebase Emulator Suite 隔离认证、Firestore、Storage 等服务。典型命令:
firebase init emulators
firebase emulators:start
命令成功后通常会输出本地监听地址和 Emulator UI 地址。Flutter 代码需要显式连接模拟器,例如 Android 模拟器访问宿主机通常使用 10.0.2.2,iOS 模拟器通常可使用 127.0.0.1;真实设备则需要使用开发机在局域网中的地址,并确保防火墙允许访问。
认证连接示例:
await FirebaseAuth.instance.useAuthEmulator(
'10.0.2.2',
9099,
);
这里的地址必须根据运行设备调整。把 127.0.0.1 写入真实 Android 设备,指向的是手机自身,而不是开发机,因此通常会连接失败。
模拟器的价值是:
- 避免开发账号进入真实项目;
- 测试 Security Rules;
- 可重复构造登录和数据场景;
- 降低误删生产数据的风险。
但模拟器不能完全替代生产验证。APNs、Android 系统通知、Web Service Worker、Crashlytics 上报和真实设备后台限制,仍然需要在受支持的真实平台上验证。
十、发布前的环境与故障验证
发布前至少应验证以下因果链,而不是只执行一次 flutter build:
初始化
- 启动日志中的 Project ID 与目标环境一致;
- Android 包名、iOS Bundle ID 与 Firebase App 一致;
- Web 的配置和域名环境一致;
- 初始化失败时应用能进入可理解的降级状态。
认证
- 新用户注册、登录、登出正常;
- 应用重启后会话恢复符合预期;
authStateChanges的等待态不会误显示登录页;- 错误提示不会泄露账号是否存在等不必要信息;
- Security Rules 在未登录、登录本人、登录他人三种情况下分别验证。
消息
- Android 13+ 通知权限已测试;
- iOS 真机 APNs 配置已测试;
- 前台、后台、终止态分别测试;
- Token 刷新会更新后端;
- 登出和切换用户不会误发给旧用户;
- 无效 Token 能被后端清理;
- Web HTTPS、权限和 Service Worker 均已验证;
- 不支持消息的桌面平台不会显示“已注册成功”的假状态。
Crash
- 测试崩溃只在测试构建可触发;
- Flutter 错误和异步平台错误都能上报;
- 非致命错误带有业务上下文;
- Release 符号文件和版本信息正确;
- 崩溃进入正确 Firebase Project;
- 日志和自定义键不包含敏感数据。
环境隔离
dev构建不能写入生产认证、数据库、Storage 和消息项目;prod构建不能使用开发包名或调试配置;- CI 能打印并校验环境、Project ID、包名和 Bundle ID;
- 配置文件按环境存放,切换 flavor 后不会残留旧文件;
- 服务端环境与客户端 Firebase Project 一致。
结语:把 Firebase 看成受平台约束的生命周期系统
Flutter Firebase 工程的难点不在于记住几个初始化 API,而在于理解它们之间的边界:
firebase_core只负责建立 Firebase App;- Authentication 负责身份和会话,不负责全部授权;
- FCM 负责消息传递,Token 是安装实例标识,不是用户身份;
- Crashlytics 负责受支持平台上的错误聚合,不是完整进程审计;
- 环境隔离要求 Dart、原生配置、构建标识和云端 Project 同时一致。
当初始化、平台能力、用户状态、消息状态和错误采集被分别建模后,工程就能针对每条故障路径给出明确判断:是配置错、权限未授予、Token 失效、生命周期时序错误、平台不支持,还是服务端授权规则拒绝。这样的结构比在入口处不断增加重试和条件判断更容易测试,也更不容易把开发流量、测试账号或崩溃数据泄漏到生产环境。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter Web 发布:Renderer、缓存、路由、CDN、PWA 和回滚
- 下一篇:Flutter 地图与定位:权限、坐标、后台定位、隐私和耗电
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论