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 配置]

这里有三个容易混淆的概念:

  1. Firebase Project:云端项目,例如 my-app-devmy-app-prod
  2. Firebase App:某个平台下注册在 Project 中的应用,例如 Android 包名为 com.example.app 的应用。
  3. Flutter 插件对象:如 FirebaseAuth.instanceFirebaseMessaging.instance,它们通过默认 Firebase App 访问对应服务。

因此,“初始化 Firebase”不是登录,也不是获取 FCM Token,更不是打开 Crashlytics。初始化只是让插件获得项目标识、应用标识、API Key 等配置,从而能连接到对应的 Firebase Project。

1. 初始化必须先于服务访问

一个常见的启动约束是:

Firebase.initializeApp 完成允许访问依赖 Firebase App 的服务\text{Firebase.initializeApp 完成} \Rightarrow \text{允许访问依赖 Firebase App 的服务}

如果在初始化完成前调用:

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_messagingfirebase_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,而是确保以下映射一致:

E{包名/Bundle ID, Firebase Project, 原生配置, Dart 配置, 后端环境}E \rightarrow \{\text{包名/Bundle ID},\ \text{Firebase Project},\ \text{原生配置},\ \text{Dart 配置},\ \text{后端环境}\}

其中 EE 是环境,如 devstagingprod。如果其中一个元素仍指向另一个环境,就会产生“半隔离”:

  • 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。正确的关系是:

  1. flavor 决定原生构建配置;
  2. --dart-define 决定 Dart 层环境标识;
  3. firebase_options.dart 或原生文件必须与前两者指向同一个 Firebase Project;
  4. 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);
  },
)

这里必须区分三个状态:

  1. waiting:Firebase 还在恢复本地会话;
  2. active + data == null:确认没有登录用户;
  3. 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 登录成功,只能推出:

request.auth.uid=A\text{request.auth.uid} = A

不能推出:

A 可以读写任意用户数据A \text{ 可以读写任意用户数据}

Firestore 规则需要继续限制资源。例如只允许用户访问自己的文档:

match /users/{userId} {
  allow read, write: if request.auth != null
                    && request.auth.uid == userId;
}

这条规则中的因果关系是:

  1. 客户端发送请求;
  2. Firebase Authentication 验证请求中的身份;
  3. Security Rules 读取 request.auth.uid
  4. 只有路径中的 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,
);

原因有两个:

  1. 后台处理可能运行在独立的 Dart isolate;
  2. 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 但收不到消息”时,应按路径逐段验证:

  1. 确认构建包连接了正确 Firebase Project;
  2. 确认 Token 来自当前安装实例且未过期;
  3. 确认 Android 13+ 或 iOS/Web 权限已授权;
  4. iOS 确认 APNs 配置和真机能力;
  5. 确认 Android 通知渠道、系统通知开关和厂商后台限制;
  6. 确认发送目标是 Token、Topic 还是用户条件;
  7. 确认前台、后台、终止态使用了正确监听入口;
  8. 检查后端发送响应和无效 Token;
  9. 通过 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('测试崩溃'),
)

验证时必须考虑:

  1. 使用真实设备或受支持平台;
  2. 使用 Debug、Profile、Release 中与项目配置一致的构建;
  3. 崩溃后重新启动应用,因为报告通常需要在下一次启动时上传;
  4. 等待控制台处理符号化和聚合;
  5. 确认报告位于正确 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 官方文档重新梳理;正文与示例由 WR BLOG 编写。