iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 16

Day 16 - Firebase Storage:檔案上傳與管理的最佳實踐

  • 分享至 

  • xImage
  •  

在 Day 15 完成 Firestore 的即時資料庫設計後,今天我們要來探討另一個重要議題:檔案管理。在 Crew Up! 專案中,使用者需要上傳個人頭像、活動照片、聊天檔案等,這些都需要 Firebase Storage 來處理。

從實際開發經驗來看,檔案上傳看似簡單,但要做好並不容易。我們需要考慮檔案壓縮、上傳進度、錯誤處理、安全規則等多個面向。

🎯 Firebase Storage 策略:Crew Up! 的實際需求

為什麼選擇 Firebase Storage?

🚀 與 Firebase 生態系統整合

  • 與 Firebase Auth 無縫整合,安全規則可以直接使用認證資訊
  • 與 Firestore 搭配使用,檔案 URL 存在資料庫中
  • 統一的管理介面和監控工具

📱 檔案管理功能完整

  • 支援斷點續傳和暫停/恢復上傳
  • 自動產生下載 URL,無需額外設定
  • 內建 CDN 加速,全球存取速度快

🔧 開發體驗良好

  • Flutter 套件成熟穩定,API 設計直觀
  • 支援上傳進度監聽,使用者體驗佳
  • 錯誤處理機制完善,容易整合到既有架構

💡 關於 Firebase 的選擇與設定,請參考 Day 13 - Firebase 專案設定

📋 依賴套件設定

# pubspec.yaml
dependencies:
  firebase_storage: ^13.0.1        # Firebase Storage SDK
  flutter_image_compress: ^2.3.0   # 圖片壓縮
  path_provider: ^2.1.5            # 取得臨時目錄
  uuid: ^4.5.1                     # 產生唯一檔名
  flutter_riverpod: ^2.6.1         # 狀態管理

💡 關於 Riverpod 狀態管理的使用,請參考 Day 6 - App 狀態管理策略

🏗️ 架構設計:統一的 StorageService

核心設計理念

在開發 Crew Up! 的過程中,我們建立了 StorageService 作為統一入口,避免程式碼重複和不一致的問題。

核心功能:

  • ✅ 自動圖片壓縮 (quality: 85, minWidth: 1080)
  • ✅ 上傳進度監聽
  • ✅ Result Pattern 錯誤封裝
  • ✅ 最終一致性處理 (重試機制)

服務實作架構

// lib/app/core/services/storage_service.dart

@riverpod
StorageService storageService(Ref ref) => StorageService();

class StorageService {
  final FirebaseStorage _storage;
  final Uuid _uuid;

  StorageService({FirebaseStorage? storage, Uuid? uuid})
    : _storage = storage ?? FirebaseStorage.instance,
      _uuid = uuid ?? const Uuid();

  /// 通用檔案上傳方法
  UploadTask uploadFile(File file, String path) {
    final ref = _storage.ref(path);
    return ref.putFile(file, SettableMetadata(contentType: _getMimeType(path)));
  }

  /// 上傳使用者頭像(覆蓋式上傳)
  Future<UploadTask> uploadProfilePhoto(String userId, File imageFile) async {
    final compressed = await _compressImage(imageFile);
    return uploadFile(compressed, 'user_photos/$userId/profile.jpg');
  }

  /// 上傳活動圖片(UUID 唯一檔名)
  Future<UploadTask> uploadActivityImage(String activityId, File imageFile) async {
    final compressed = await _compressImage(imageFile);
    return uploadFile(compressed, 'activity_images/$activityId/${_uuid.v4()}.jpg');
  }

  /// 上傳聊天室檔案(自動壓縮圖片)
  Future<UploadTask> uploadChatFile(String chatRoomId, File file) async {
    final fileToUpload = _isImage(file.path) ? await _compressImage(file) : file;
    final fileName = '${_uuid.v4()}_${p.basename(file.path)}';
    return uploadFile(fileToUpload, 'chat_files/$chatRoomId/$fileName');
  }

  /// 取得下載 URL(含最終一致性處理)
  Future<Result<String>> getDownloadUrlFromTask(UploadTask task) async {
    try {
      final ref = (await task).ref;

      // 輪詢等待物件可見 + 退避重試取得 URL
      await _waitForObjectVisible(ref);
      final url = await _getDownloadUrlWithRetry(ref);

      return Success(url);
    } on FirebaseException catch (e) {
      return Failure(_mapStorageException(e));
    }
  }
}

💡 關於 Result Pattern 的使用,請參考 Day 10 - 錯誤處理與日誌記錄

路徑命名策略

📁 使用者頭像

  • 路徑:user_photos/{userId}/profile.jpg
  • 特點:固定檔名,覆蓋式上傳,節省儲存空間

📁 活動圖片

  • 路徑:activity_images/{activityId}/{uuid}.jpg
  • 特點:UUID 確保檔名唯一,支援多張圖片

📁 聊天檔案

  • 路徑:chat_files/{chatRoomId}/{uuid}_{originalName}
  • 特點:保留原檔名方便識別,UUID 確保唯一性

🎨 圖片壓縮:效能與成本優化

壓縮策略

/// 壓縮圖片(StorageService 私有方法)
Future<File> _compressImage(File file) async {
  try {
    final tempDir = await getTemporaryDirectory();
    final targetPath = p.join(tempDir.path, '${_uuid.v4()}.jpg');

    final result = await FlutterImageCompress.compressAndGetFile(
      file.absolute.path,
      targetPath,
      quality: 85,        // 視覺品質與檔案大小的平衡點
      minWidth: 1080,     // 適合大多數螢幕顯示
    );

    return result != null ? File(result.path) : file;
  } catch (e) {
    return file; // 壓縮失敗時使用原檔
  }
}

/// 判斷是否為圖片檔案
bool _isImage(String path) {
  final ext = p.extension(path).toLowerCase();
  return ext == '.jpg' || ext == '.jpeg' || ext == '.png';
}

/// 根據副檔名取得 MIME 類型
String? _getMimeType(String path) {
  final ext = p.extension(path).toLowerCase();
  const mime = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.gif': 'image/gif',
    '.pdf': 'application/pdf',
    '.doc': 'application/msword',
    '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  };
  return mime[ext];
}

📊 壓縮效果:

  • 原始照片:5-10 MB
  • 壓縮後:500 KB - 2 MB
  • 壓縮率:約 80-90%
  • 視覺品質:肉眼難以分辨差異

📡 上傳進度監聽:提升使用者體驗

Notifier 層實作

// 在 Notifier 中管理上傳進度
class MessageChatNotifier extends _$MessageChatNotifier {
  StreamSubscription<TaskSnapshot>? _uploadSubscription;

  Future<void> sendFileMessage(File file) async {
    try {
      final storage = ref.read(storageServiceProvider);
      final uploadTask = await storage.uploadChatFile(state.chat.id, file);

      // 監聽上傳進度
      _uploadSubscription = uploadTask.snapshotEvents.listen((snapshot) {
        final progress = snapshot.totalBytes == 0
            ? 0.0
            : snapshot.bytesTransferred / snapshot.totalBytes;

        // 更新狀態,觸發 UI 重建
        state = state.copyWith(
          uploadProgress: progress,
          uploadingFileName: p.basename(file.path),
        );
      });

      // 取得 URL 並建立訊息...
    } finally {
      // 清理訂閱
      await _uploadSubscription?.cancel();
      state = state.copyWith(clearUploadProgress: true);
    }
  }

  @override
  void dispose() {
    _uploadSubscription?.cancel();
    super.dispose();
  }
}

UI 層展示

在 UI 中,我們可以根據 uploadProgress 狀態顯示進度指示器:

// 在聊天畫面中顯示上傳進度(使用 @theme/ tokens)
import 'package:crew_up/app/config/theme/app_colors.dart';
import 'package:crew_up/app/config/theme/app_spacing.dart';
import 'package:crew_up/l10n/app_localizations.dart';

class MessageChatScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(messageChatNotifierProvider);

    return Scaffold(
      backgroundColor: AppColors.surfaceBackground2,
      body: Column(
        children: [
          // 訊息列表
          Expanded(child: MessageList()),

          // 上傳進度指示器
          if (state.uploadProgress != null)
            _buildUploadProgressIndicator(context, state),

          // 輸入框
          MessageInputField(),
        ],
      ),
    );
  }

  Widget _buildUploadProgressIndicator(BuildContext context, MessageChatState state) {
    return Container(
      padding: const EdgeInsets.symmetric(
        horizontal: AppSpacing.m,
        vertical: AppSpacing.s,
      ),
      decoration: const BoxDecoration(
        color: AppColors.surfaceWhite,
        border: Border(
          top: BorderSide(color: AppColors.surfaceBorder, width: 1),
        ),
      ),
      child: Row(
        children: [
          // 環形進度條
          const SizedBox(
            width: 24,
            height: 24,
            child: CircularProgressIndicator(
              strokeWidth: 2,
              valueColor: AlwaysStoppedAnimation<Color>(AppColors.actionPrimary),
              backgroundColor: AppColors.surfaceBackground2,
            ),
          ),
          const SizedBox(width: AppSpacing.s),

          // 檔名與百分比
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  '${S.of(context).loading} ${state.uploadingFileName ?? ''}',
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(
                    fontSize: 14,
                    color: AppColors.textSecondary,
                  ),
                ),
                const SizedBox(height: AppSpacing.xs),
                Text(
                  '${(state.uploadProgress! * 100).toStringAsFixed(0)}%',
                  style: const TextStyle(
                    fontSize: 12,
                    color: AppColors.actionPrimary,
                  ),
                ),
              ],
            ),
          ),

          // 取消按鈕(可選)
          IconButton(
            icon: const Icon(Icons.close, size: 20, color: AppColors.textTertiary),
            onPressed: () {
              // 取消上傳邏輯
            },
          ),
        ],
      ),
    );
  }
}

🎯 UI 展示要點:

  • 環形進度條CircularProgressIndicator(value: progress) 顯示 0-1 的進度值
  • 百分比文字(progress * 100).toStringAsFixed(0)% 顯示具體百分比
  • 檔案資訊:顯示正在上傳的檔名,讓使用者知道上傳的是哪個檔案
  • 取消按鈕:可選功能,允許使用者取消上傳

📱 使用者體驗:

[上傳前] 選擇檔案 → 點擊發送
[上傳中] 顯示進度條 → 12% → 45% → 78% → 100%
[上傳後] 進度條消失 → 訊息顯示在聊天室

🔄 資源管理:

  • 使用 StreamSubscription 管理訂閱
  • finally 中清理資源
  • dispose 中取消訂閱,防止 memory leak

🔐 安全規則設計:Storage 安全策略

完整的安全規則

// storage.rules
rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {

    // 使用者頭像:只有本人可寫入,任何人可讀取
    match /user_photos/{userId}/{fileName} {
      allow write: if request.auth != null
                    && request.auth.uid == userId
                    && request.resource.size < 2 * 1024 * 1024
                    && request.resource.contentType.matches('image/.*');
      allow read: if request.auth != null;
    }

    // 活動圖片:登入使用者可寫入,任何人可讀取
    match /activity_images/{activityId}/{fileName} {
      allow write: if request.auth != null
                    && request.resource.size < 5 * 1024 * 1024
                    && request.resource.contentType.matches('image/.*');
      allow read: if request.auth != null;
    }

    // 聊天檔案:只有聊天室成員可讀寫 ⚠️ 重要
    match /chat_files/{chatRoomId}/{fileName} {
      function isMember() {
        return request.auth != null &&
               exists(/databases/(default)/documents/chat_rooms/$(chatRoomId)) &&
               get(/databases/(default)/documents/chat_rooms/$(chatRoomId)).data.members.hasAny([request.auth.uid]);
      }

      allow read, write: if isMember();
    }

    // 預設拒絕所有存取
    match /{allPaths=**} {
      allow read, write: if false;
    }
  }
}

🛡️ 設計原則:

最小權限原則

  • 使用者只能上傳到自己的資料夾
  • 聊天檔案只有成員可存取(透過 Firestore 驗證)

型別與大小驗證

  • 圖片類型檔案必須是 image/*
  • 明確的檔案大小限制(2MB/5MB)

與 Firestore 整合

  • 使用 get() 查詢聊天室成員資料
  • 確保權限邏輯一致

預設拒絕

  • 未匹配的路徑一律拒絕存取

💡 關於 Firestore 安全規則設計,請參考 Day 15 - Cloud Firestore

⚠️ 生產環境關鍵問題:Storage 最終一致性

問題現象

部署到生產環境後,我們遇到一個嚴重問題:使用者上傳檔案後頻繁看到「上傳失敗」的錯誤訊息

這是 Firebase Storage 的最終一致性 (Eventual Consistency) 造成的:

上傳完成 → 立即取得 URL → object-not-found 錯誤 ❌
等待幾秒 → 再次取得 URL → 成功 ✅

解決方案:指數退避重試機制

我們實作了完整的重試機制來處理這個問題:

Step 1: 等待物件可見

/// 輪詢等待物件在後端可見
Future<void> _waitForObjectVisible(Reference ref) async {
  const backoff = [
    Duration(milliseconds: 200),
    Duration(milliseconds: 400),
    Duration(seconds: 1),
    Duration(seconds: 2),
    Duration(seconds: 3),
  ];

  for (int i = 0; i < backoff.length; i++) {
    try {
      await ref.getMetadata();
      developer.log('✅ Metadata 可見: ${ref.fullPath}');
      return; // 成功取得 metadata,物件已可見
    } on FirebaseException catch (e) {
      if (e.code == 'object-not-found' && i < backoff.length - 1) {
        developer.log('⏳ Metadata 尚未可見,等待 ${backoff[i]}');
        await Future.delayed(backoff[i]);
        continue;
      }
      // 最後一次仍失敗,或其他錯誤,跳出迴圈
      break;
    }
  }
}

Step 2: 退避重試取得 URL

/// 以退避方式取得下載 URL
Future<String> _getDownloadUrlWithRetry(Reference ref) async {
  const backoff = [
    Duration(milliseconds: 200),
    Duration(seconds: 1),
    Duration(seconds: 2),
    Duration(seconds: 3),
  ];

  for (int i = 0; i <= backoff.length; i++) {
    try {
      final url = await ref.getDownloadURL();
      developer.log('🔗 成功取得 URL: ${ref.fullPath}');
      return url;
    } on FirebaseException catch (e) {
      if (e.code == 'object-not-found' && i < backoff.length) {
        developer.log('⏳ URL 尚未就緒,等待 ${backoff[i]}');
        await Future.delayed(backoff[i]);
        continue;
      }
      // 超過重試次數或其他錯誤
      rethrow;
    }
  }

  throw Exception('Failed to get download URL after retries');
}

完整整合:

/// 取得下載 URL(含完整重試機制)
Future<Result<String>> getDownloadUrlFromTask(UploadTask task) async {
  try {
    final snapshot = await task;
    final ref = snapshot.ref;

    // Step 1: 輪詢等待物件可見(最多 ~6.6 秒)
    await _waitForObjectVisible(ref);

    // Step 2: 退避重試取得 URL(最多 ~6 秒)
    final url = await _getDownloadUrlWithRetry(ref);

    return Success(url);
  } on FirebaseException catch (e) {
    return Failure(_mapStorageException(e));
  }
}

核心策略說明:

  • 🔄 兩階段重試:先等待 metadata,再取得 URL
  • ⏱️ 指數退避:200ms → 400ms → 1s → 2s → 3s
  • 📊 總等待時間:最多約 12 秒(實測 95% 在 1 秒內完成)
  • 🎯 優雅降級:超過重試次數則拋出明確錯誤

使用者體驗對比:

  • ❌ 沒有重試機制:使用者看到「上傳失敗」,需要重新上傳
  • ✅ 有重試機制:自動處理,使用者無感知

💡 重要提醒:這是生產環境必須處理的問題,本地開發通常不會遇到。務必在實際環境中驗證!

🔧 錯誤處理與多國語系

錯誤映射策略(專案多國語系版)

String? _mapStorageErrorMessage(FirebaseException e) {
  final S l10n = LocalizationService.instance.current;
  final code = e.code.toLowerCase();
  final message = (e.message ?? '').toLowerCase();

  // 計費/方案受限
  final needsBilling =
      code == 'permission-denied' ||
      message.contains('billing') ||
      message.contains('upgrade') ||
      message.contains('quota') ||
      message.contains('limit');
  if (needsBilling) return l10n.firebaseStorageBillingRequired;

  // bucket 未配置
  if (code == 'bucket-not-found' || message.contains('bucket')) {
    return l10n.firebaseStorageBucketNotFound;
  }

  // 權限
  if (code == 'permission-denied' || code == 'unauthorized') {
    return l10n.firebaseStoragePermissionDenied;
  }

  // 檔案大小
  if (code == 'file-too-large' || message.contains('size')) {
    return l10n.firebaseStorageFileTooLarge;
  }

  // 網路
  if (code == 'network-request-failed' || message.contains('network')) {
    return l10n.firebaseStorageNetworkError;
  }

  // 物件不存在
  if (code == 'object-not-found') {
    return l10n.firebaseStorageObjectNotFound;
  }

  return null;
}

以上邏輯會優先回傳已翻譯的錯誤字串;若回傳為 null,呼叫端會退回到通用英文訊息(包含 bucket/path 記錄),利於偵錯。

💡 關於完整的錯誤處理機制和多國語系,請參考:

🔄 與 Firestore 的整合

完整的頭像更新流程

// 1. 上傳檔案到 Storage
final storage = ref.read(storageServiceProvider);
final uploadTask = await storage.uploadProfilePhoto(userId, imageFile);

// 2. 取得下載 URL
final urlResult = await storage.getDownloadUrlFromTask(uploadTask);

// 3. 更新 Firestore 文件
urlResult.fold(
  (url) async {
    await FirebaseFirestore.instance
        .collection('user_profiles')
        .doc(userId)
        .set({'photoUrl': url}, SetOptions(merge: true));

    developer.log('✅ 頭像更新完成');
  },
  (error) {
    developer.log('❌ 頭像更新失敗: ${error.message}');
  },
);

🎯 整合重點:

  • 先 Storage 後 Firestore:確保檔案上傳成功再更新資料庫
  • 使用 merge: true:只更新 photoUrl 欄位,不影響其他資料
  • 完整的錯誤處理:任何步驟失敗都能正確處理

💡 關於 Firestore 的使用,請參考 Day 15 - Cloud Firestore

🚀 核心觀念總結

透過今天的實戰探索,我們學到了以下核心觀念:

✅ 統一入口的價值

  • StorageService 統一管理所有上傳邏輯
  • 避免程式碼重複,提升可維護性

✅ 檔案壓縮節省成本

  • 自動壓縮圖片降低流量和儲存費用
  • 視覺品質與檔案大小的平衡

✅ 安全規則要完善

  • 路徑命名與安全規則必須一致
  • 明確的檔案大小和型別限制

✅ 進度顯示提升體驗

  • 正確管理訂閱生命週期
  • 防止 memory leak

⚠️ 生產環境問題不可忽視

  • 最終一致性是真實存在的問題
  • 必須實作重試機制
  • 不能只在本地測試,要在生產環境驗證

實際開發中的好處

🚀 開發效率提升

  • 統一的 StorageService 讓功能開發更快速
  • 完整的錯誤處理減少除錯時間

📱 使用者體驗優化

  • 自動壓縮讓上傳更快速
  • 進度顯示讓使用者更安心
  • 友善的錯誤訊息幫助使用者解決問題

💰 成本控制

  • 圖片壓縮大幅降低流量費用
  • 頭像覆蓋上傳節省儲存空間
  • 安全規則防止惡意上傳

下一步

明天,我們將深入探討 Firebase Cloud Messaging,學習如何實作推播通知功能,讓使用者即時收到活動更新、新訊息等通知。

期待與您在 Day 17 相見!


📋 相關資源

🔗 系列文章

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 16 - Firebase Storage:檔案上傳與管理的最佳實踐
  • 文章日期: 2025-09-30
  • 技術棧: Firebase Storage, Flutter Image Compress, Riverpod, Clean Architecture

上一篇
Day 15 - Cloud Firestore:即時資料庫設計與優化
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言