在 Day 15 完成 Firestore 的即時資料庫設計後,今天我們要來探討另一個重要議題:檔案管理。在 Crew Up! 專案中,使用者需要上傳個人頭像、活動照片、聊天檔案等,這些都需要 Firebase Storage 來處理。
從實際開發經驗來看,檔案上傳看似簡單,但要做好並不容易。我們需要考慮檔案壓縮、上傳進度、錯誤處理、安全規則等多個面向。
🚀 與 Firebase 生態系統整合
📱 檔案管理功能完整
🔧 開發體驗良好
💡 關於 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 狀態管理策略
在開發 Crew Up! 的過程中,我們建立了 StorageService
作為統一入口,避免程式碼重複和不一致的問題。
核心功能:
// 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
📁 聊天檔案
chat_files/{chatRoomId}/{uuid}_{originalName}
/// 壓縮圖片(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];
}
📊 壓縮效果:
// 在 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 中,我們可以根據 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.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;
}
}
}
🛡️ 設計原則:
最小權限原則
型別與大小驗證
image/*
與 Firestore 整合
get()
查詢聊天室成員資料預設拒絕
💡 關於 Firestore 安全規則設計,請參考 Day 15 - Cloud Firestore
部署到生產環境後,我們遇到一個嚴重問題:使用者上傳檔案後頻繁看到「上傳失敗」的錯誤訊息。
這是 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));
}
}
核心策略說明:
使用者體驗對比:
💡 重要提醒:這是生產環境必須處理的問題,本地開發通常不會遇到。務必在實際環境中驗證!
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 記錄),利於偵錯。
💡 關於完整的錯誤處理機制和多國語系,請參考:
// 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}');
},
);
🎯 整合重點:
💡 關於 Firestore 的使用,請參考 Day 15 - Cloud Firestore
透過今天的實戰探索,我們學到了以下核心觀念:
✅ 統一入口的價值
✅ 檔案壓縮節省成本
✅ 安全規則要完善
✅ 進度顯示提升體驗
⚠️ 生產環境問題不可忽視
🚀 開發效率提升
📱 使用者體驗優化
💰 成本控制
明天,我們將深入探討 Firebase Cloud Messaging,學習如何實作推播通知功能,讓使用者即時收到活動更新、新訊息等通知。
期待與您在 Day 17 相見!