# 使用 Flutter 創建聊天室 app 原始碼連結:<https://github.com/WangShuan/flutter-07-chat-app> ## 設置及初始化 Firebase 首先要安裝 Firebase SDK 以簡化使用 Firebase 的大部分流程,可參考官方文檔: <https://firebase.google.com/docs/flutter/setup?hl=zh-tw&platform=ios> 安裝步驟如下: 1. 執行指令 `npm install -g firebase-tools` 安裝 Firebase CLI 2. 執行指令 `firebase login` 登入你的 Firebase 帳號(會自動開啟瀏覽器讓你選擇登入的 Google 帳號) 3. 執行指令 `dart pub global activate flutterfire_cli` 安裝 Firebase CLI (有可能需要加上 sudo 才可執行) 4. 執行命令 `flutterfire configure` 讓 flutter 項目綁定 Firebase 專案(步驟依序為選擇 Firebase 專案、選擇要使用的平台(選 IOS 與 Android) 、直接 Enter Yes 同意自動變更檔案) > 執行第四步時如果出現錯誤 `zsh: command not found: flutterfire` 請開啟 `.zshrc` 檔案,添加 `export PATH="$PATH":"$HOME/.pub-cache/bin"` 保存並重開終端機。 > 執行第四步時如果出現錯誤 `no active package flutterfire_cli.` 請執行命令 `sudo flutterfire configure` 即可正確運行。 最後回到 flutter 專案中開啟 `main.dart` 檔案,修改以下內容以進行初始化: ```dart // 引入 import 'package:firebase_core/firebase_core.dart'; import './firebase_options.dart'; //改寫 main 函數 void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); runApp(const App()); } ``` 完成後請將 flutter 執行中的應用程式完全關閉並重新啟動即可。 > 如出現錯誤找不到 `project.pbxproj` 檔案,請用 Finder 進入檔案所在位置,並點擊右鍵,選擇取得資訊,更改讀取與寫入權限,再將 flutter 項目重啟即可。 ## 使用 Authentication 服務(package: firebase_auth) 這邊要通過 `firebase_auth` 使用 Firebase 的 Authentication 服務讓用戶可通過電子郵件及密碼進行登入、註冊等功能。 ### 登入與註冊 先到 Firebase 控制台中,點擊進入 “建構 > Authentication” 啟用 “電子郵件/密碼” 登入供應商,接著回到 flutter 項目中安裝 `firebase_auth` 並將 flutter 執行中的應用程式完全關閉,重新啟動。 接下來即可到 `auth_screen.dart` 中,在 `_submit` 函數裡面藉由判斷 `isLogin` 分別處理登入與註冊事件: ```dart // 引入 import 'package:firebase_auth/firebase_auth.dart'; // 宣告 final _firebase = FirebaseAuth.instance; // 使用 Future<void> _submit() async { if (!formKey.currentState!.validate()) return; // 驗證表單 formKey.currentState!.save(); // 儲存表單內容 if (_isLogin) { // 判斷是否為登入 try { // 通過 _firebase.signInWithEmailAndPassword 傳入信箱與密碼進行登入 await _firebase.signInWithEmailAndPassword(email: _mail!, password: _pwd!); } on FirebaseAuthException catch (e) { // 處理 firebase 提供的錯誤內容 switch (e.code) { case 'user-not-found': _showSnackBar('找不到對應於該電子郵件的使用者。'); break; case 'wrong-password': _showSnackBar('您輸入的帳號或密碼錯誤。'); break; case 'invalid-email': _showSnackBar('您輸入的電子郵件地址無效。'); break; case 'user-disabled': _showSnackBar('該使用者的帳號已被停用。'); break; default: _showSnackBar(e.code); break; } } catch (e) { // 處理其他錯誤 _showSnackBar(e.toString()); } } else { try { // 通過 _firebase.createUserWithEmailAndPassword 傳入信箱與密碼進行註冊 await _firebase.createUserWithEmailAndPassword(email: _mail!, password: _pwd!); } on FirebaseAuthException catch (e) { // 處理 firebase 提供的錯誤內容 switch (e.code) { case 'email-already-in-use': _showSnackBar('此電子郵件地址已被使用。'); break; case 'invalid-email': _showSnackBar('您輸入的電子郵件地址無效。'); break; case 'operation-not-allowed': _showSnackBar('電子郵件/密碼註冊功能尚未啟用。'); break; case 'weak-password': _showSnackBar('密碼強度不足。'); break; default: _showSnackBar(e.code); break; } } catch (e) { // 處理其他錯誤 _showSnackBar(e.toString()); } } } ``` ### 登出 建立 `chat_screen.dart` 檔案,簡單設置好 appBar 小部件,在 actions 中新增 IconButton 綁定點擊事件,使用 `FirebaseAuth.instance.signOut()` 進行登出: ```dart appBar: AppBar( title: const Text('CHAT APP'), actions: [ IconButton( onPressed: () => FirebaseAuth.instance.signOut(), icon: const Icon(Icons.logout_rounded), ), ], ) ``` ### 判斷當前用戶資訊 在 `main.dart` 檔案中的 home 區塊內容改寫如下: ```dart StreamBuilder( stream: FirebaseAuth.instance.authStateChanges(), // 獲取當前用戶資訊 builder: (context, snapshot) => snapshot.connectionState == ConnectionState.waiting ? Scaffold( // 載入中顯示 loading 畫面 appBar: AppBar(title: const Text('CHAT APP')), body: const Center(child: CircularProgressIndicator()), ) : snapshot.hasData // 判斷有無資訊 ? const ChatScreen() // 如果已登入則顯示 ChatScreen 畫面 : const AuthScreen(), // 如果未登入則顯示 AuthScreen 畫面 ) ``` ## 使用 Storage 服務(package: firebase_storage) 這邊要透過 `firebase_storage` 及 `image_picker` 讓用戶於註冊時可拍攝上傳用戶頭像並將圖片保存到 Firebase 的 Storage 服務中。 先到 Firebase 控制台中,點擊進入 “建構 > Storage” 啟用服務(選擇正式版本、選個亞洲地區即可),完成後點擊 `Rules` 修改規則,將 `if false` 更改為 `if request.auth != null` 限制已登入的用戶才可以進行讀取及寫入,並點擊發布規則,接下來回到 flutter 專案中,安裝 `firebase_storage` 及 `image_picker` 用來保存用戶頭像,安裝好 pub 記得要將 flutter 執行中的應用程式完全關閉並重新啟動。 接著回到 `auth_screen.dart` 中通過 `_submit` 方法,於註冊時,藉由 `createUserWithEmailAndPassword` 方法獲取回傳的結果為 `res` ,通過 `res.user.uid` 獲取用戶 `uid` ,即可將圖片檔案設置名稱為 `'$uid.jpg'` 後上傳到 `firebase_storage` 中,並通過 `firebase_storage` 提供的 `getDownloadURL` 方法得到圖片網址。 整體主要程式碼如下: ```dart try { final userCredential = await _firebase.createUserWithEmailAndPassword(email: _mail!, password: _pwd!); // 獲取用戶資料 final storageRef = FirebaseStorage.instance.ref().child("user_images").child("${userCredential.user!.uid}.jpg"); // 設置欲上傳的檔案路徑 try { await storageRef.putFile(_selectedImg!); // 上傳文件 final imgUrl = await storageRef.getDownloadURL(); // 獲取文件 URL await userCredential.user?.updatePhotoURL(imgUrl); // 設置用戶頭像 } on FirebaseException catch (e) { // 處理 FirebaseStorage 的錯誤 _handleError(e); } } on FirebaseAuthException catch (e) { // 處理 FirebaseAuth 的錯誤 _handleError(e); } catch (e) { // 處理其他錯誤 _handleError(e.toString()); } ``` ## 使用 Firestore Database 服務(package: cloud_firestore) 首先要到 Firebase 控制台中,點擊進入 “建構 > Firestore Database” ,這邊會顯示要你到 Google Cloud 控制台,請先點擊進入 Google Cloud 控制台,並點擊切換到本地模式,,完成後回到 Firebase 控制台,點擊 `Rules` 修改規則,將 `if false` 更改為 `if request.auth != null` 限制已登入的用戶才可以進行讀取及寫入,並點擊發布規則,接下來回到 flutter 專案中,安裝 `cloud_firestore` 即可。 上述步驟皆完成後,請記得要將 flutter 執行中的應用程式完全關閉並重新啟動。 如果重啟服務時一直停在 pod install 可透過以下步驟改善: 1. 開啟 `ios/Podfile` 檔案 2. 找到 `target 'Runner' do` 的程式碼 3. 於下一行貼上 `pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '7.11.0'` 4. 將 `'7.11.0'` 替換為 <https://github.com/firebase/flutterfire/blob/master/packages/firebase_core/firebase_core/ios/firebase_sdk_version.rb> 網址中顯示的版本 5. 再次重新啟動服務,即可縮短編譯時間 ### 保存用戶資料 在 `auth_screen.dart` 的註冊函數中,需添加一段程式碼,將用戶註冊的資料保存到 Firestore Database : ```dart try { final userCredential = await _firebase.createUserWithEmailAndPassword(email: _mail!, password: _pwd!); // 獲取用戶資料 final storageRef = FirebaseStorage.instance.ref().child("user_images").child("${userCredential.user!.uid}.jpg"); // 設置欲上傳的檔案路徑 try { await storageRef.putFile(_selectedImg!); // 上傳文件 final imgUrl = await storageRef.getDownloadURL(); // 獲取文件 URL await userCredential.user?.updatePhotoURL(imgUrl); // 設置用戶頭像 final db = FirebaseFirestore.instance; // 添加的內容如下,透過 collection 建立集合 users => 透過 doc 建立文檔 uid => 透過 set 設置文檔內容 await db.collection('users').doc(userCredential.user!.uid).set({ 'username': _name, // 添加一個 TextFormField 小部件用來設置姓名並傳到此處 'email': _mail, 'image_url': imgUrl, // 傳入上方通過 FirebaseStorage 獲取到的文件 URL }); } on FirebaseException catch (e) { // 處理 FirebaseStorage 的錯誤 _handleError(e); } } on FirebaseAuthException catch (e) { // 處理 FirebaseAuth 的錯誤 _handleError(e); } catch (e) { // 處理其他錯誤 _handleError(e.toString()); } ``` ### 發送並保存訊息 回到 `chat_screen.dart` 中,在聊天屏幕上方應該要有一個可滾動的區塊,用以顯示所有訊息,而最下方則要有一個文字輸入框及傳送按鈕用來新增訊息,所以這邊總共要再建立兩個子部件,分別是 `chat_messages.dart` 以及 `new_message.dart` 。 在 `chat_messages.dart` 中可通過 `FirebaseFirestore.instance.collection('messages').snapshots()` 獲取 `stream` 即時顯示所有訊息: ```dart final user = FirebaseAuth.instance.currentUser; // 獲取當前登入者資訊 final Stream<QuerySnapshot<Map<String, dynamic>>> messagesStream = FirebaseFirestore.instance.collection('messages').orderBy("create_at", descending: true).snapshots(); // 設置 stream,可通過 .orderBy("create_at", descending: true) 設置 data 排序方式 Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: StreamBuilder( // 通過 StreamBuilder 獲取結果 stream: messagesStream, // 傳入上方宣告好的 stream builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center( child: CircularProgressIndicator(), ); } else if (snapshot.hasError) { return const Center( child: Text('Something wrong here.'), ); } else if (!snapshot.hasData || snapshot.data!.docs.isEmpty) { // 判斷是否有值&該值底下的文檔是否為空 return const Center( child: Text('No messages here.'), ); } else { return ListView.builder( // 使用 ListView.builder 建立滾動區域 padding: const EdgeInsets.all(0), reverse: true, // 設置排序方式為倒敘,可確保最新的項目永遠為在畫面最下方 itemCount: snapshot.data!.docs.length, itemBuilder: (context, index) => MessageItem(user, snapshot.data!.docs[index].data()), ); } }, ), ); ``` 在 `new_message.dart` 中則可通過 `FirebaseFirestore.instance.collection('messages').add()` 添加訊息到 Firestore Database 中: ```dart Future<void> _sendMessage() async { final msg = _messageConntroller.text; // 獲取輸入的內容 if (msg.isEmpty || msg.trim().isEmpty) return; // 判斷是否有值 FocusScope.of(context).unfocus(); // 關閉鍵盤 _messageConntroller.clear(); // 清空輸入框 final User? user = FirebaseAuth.instance.currentUser; // 獲取當前登入用戶資訊 final db = FirebaseFirestore.instance; await db.collection('messages').add({ // 添加訊息資料到 Firestore Database 中 'user_id': user!.uid, // 傳入 uid 'user_image': user.photoURL, // 傳入頭像 'username': user.displayName, // 傳入姓名 'text': msg, // 傳入輸入的內容 'create_at': Timestamp.now(), // 傳入當前時間 }); } ``` ## 使用 Firebase Messaging 服務(package: firebase_messaging) 這邊用來再有人傳送新訊息時,向其他人的設備發送推播通知。 初始化設定,針對 ios 請先用 Xcode 開啟專案項目的 `ios/Runner.xcworkspace` 檔案,接著在左側檔案列表中點擊 `Runner` ,右側主畫面中點擊 `Signing & Capabilities` , 首先要啟用推送通知,請點擊 `+ Capability` 於搜尋欄位輸入 `push` ,雙擊結果中的 `Push Notifications` 啟用它,然後會看到主畫面中顯示一些錯誤警告,請將 `Bundle Identifier` 設置成唯一值,接著點擊鍵盤的 Enter 鍵保存。 接著要啟用後台獲取和遠程通知後台執行模式,請點擊 `+ Capability` 於搜尋欄位輸入 `back` ,雙擊結果中的 `Background Modes` 啟用它,然後會看到出現一些可勾選的項目,請將 `Background fetch` 及 `Remote notifications` 打勾即可。 然後進入 Apple 開發者頁面,登入付費的開發者帳號,點擊進入 Certificates, IDs & Profiles 底下的 Keys 中,點擊左上角+號新增 key ,設置可識別的名稱,並將 `Apple Push Notifications service (APNs)` 勾選,點擊右上角繼續,再點擊註冊,然後點擊下載檔案將金鑰的 `.p8` 檔案保存好,然後切記先不要關閉當前頁面。 接下來先進入 Firebase 控制台中,點擊專案設定,點擊雲端通訊,在 Apple 應用程式設定中選擇你的 ios 應用程式,於 APN 驗證金鑰處點擊上傳,選擇剛才的 `.p8` 檔案,輸入金鑰 ID (在 Apple 開發者頁面下載金鑰的 Key ID)與團隊 ID(在 Apple 開發者頁面的右上角你名字旁邊),點擊上傳。 然後一樣在 Firebase 控制台中的專案設定,點進一般設定裡面,將目前已經存在的應用程式刪除,重新回到 flutter 項目中執行指令 `flutterfire configure` ,以更新稍早變更過的 `Bundle Identifier` 。 接著再執行指令 `flutter pub add firebase_messaging` 安裝 Messaging 用的 package ,安裝好後記得關閉應用程式並重新啟動,然後在 `chat_screen.dart` 中獲取設備 token 稍後用來測試發送通知: ```dart void setupMsg() async { await FirebaseMessaging.instance.requestPermission(); final t = await FirebaseMessaging.instance.getToken(); print('token:$t'); } @override void initState() { setupMsg(); super.initState(); } ``` 接著於 flutter 應用程式中登入或註冊帳號,並將 chat app 滑到背景執行。 最後進入 Firebase 控制台中,點擊左側 “互動交流 > Messaging” ,點擊建立第一個廣告活動,選擇 Firebase 通知訊息,輸入通知標題及通知文字,點擊傳送測試訊息,將剛才的 token 貼到 “新增 FCM 註冊憑證” 上並點擊 “測試” 即可收到推播通知,點擊通知即可開啟應用程式。 ## 使用 Firebase Functions 服務 這邊主要是用來自動化的發送推播通知,上一段我們都是通過 Firebase 控制台手動測試發送通知,實際上則需要藉由後端處理自動化的程式碼,所以要到 Firebase 中的 “建構 > Functions“ 點擊使用(需要升級付費版本,但可放心,有免費用量的扣打)。 首先會要求你安裝 firebase-tool 請執行指令 `sudo npm install -g firebase-tools` 進行安裝(必須先安裝 node) 接著按照步驟執行命令 `firebase init` 啟動專案,第一步是選擇要啟用的功能,這邊只需用空白鍵選取 Functions 即可,接著會問你專案,選擇現有專案即可,然後要選擇使用的編程語言,這邊選 JS ,接著會問要不要啟用 ESLint 選 No ,最後會問要不要安裝依賴項目,請選 Yes ,完成後 flutter 項目中會自動產生新的資料夾 `functions` 我們主要編寫的後端程式碼在 `functions/index.js` 檔案中,編寫完畢執行命令 `firebase deploy` 即可成功部署 Functions 。 `functions/index.js` 檔案內容如下: ```dart const functions = require('firebase-functions'); // 引入 functions const admin = require('firebase-admin'); // 引入 admin admin.initializeApp(); // 初始化 admin 對象 // 定義 sendNotificationOnNewMessage 事件 exports.sendNotificationOnNewMessage = functions.firestore .document('messages/{messageId}') .onCreate(async (snapshot, context) => { // 在文檔被創建時觸發 const messageData = snapshot.data(); const payload = { notification: { title: messageData.username + '發送了一條訊息', body: messageData.text, clickAction: 'FLUTTER_NOTIFICATION_CLICK', }, }; return admin.messaging().sendToTopic("chatapp", payload); // 向主題 chatapp 發送 FCM 消息 }); ``` 要使用主題發送消息,需要在 `chat_screen.dart` 中讓用戶訂閱主題,該行為應該是被動執行,且要確保用戶曾經登入或註冊過帳號,由於在 `chat_screen.dart` 的父層會確保用戶進行過身份驗證才能進入該畫面,所以將訂閱事件設置於此: ```dart class _ChatScreenState extends State<ChatScreen> { void setupMsg() async { // 添加函數用來訂閱主題 await FirebaseMessaging.instance.requestPermission(); FirebaseMessaging.instance.subscribeToTopic("chatapp"); } @override void initState() { setupMsg(); // 於 initState 中調用訂閱主題的事件 super.initState(); } @override Widget build(BuildContext context) { return Scaffold( // ... ); } } ``` 使用主題發送消息類似於群發的概念,會將消息發送給所有訂閱過該主題的設備。 另外也可以針對單個用戶進行發送,做法即為獲取該用戶的 token 後通過 sendToDevice() 方法將 FCM 消息發送到與提供的 token 相對應的單個設備。 其他發送的方法可參考[官方文件說明](https://firebase.google.com/docs/reference/admin/node/firebase-admin.messaging.messaging?hl=zh-tw)