# 使用 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)