결과 화면
ios는 실물 기기로, android는 에뮬레이터로 녹화하였습니다.
회원가입 및 로그인
실시간 채팅 및 push알림
background, quit, foreground상태에 따른 push 알림
이미지와 음성메시지 보내고 확인. 확인시 1 사라짐
ios
android
사진 자세히보기, 로그아웃
정리
구현기능
- 텍스트,이미지 및 음성 메시지를 전송할 수 있는 1:1 채팅앱
기능
- 회원가입,로그인
- 프로필 이미지 등록
- 사용자 리스트
- 채팅방
- 텍스트, 이미지, 오디오 메시지 전송
- 메시지 읽음 표시
- 푸시 알림
목표
- Typescript 기반 React Naitve 프로젝트를 CLI를 이용해서 초기화
- 멀티미디어 다루기 (이미지, 오디오 녹음 및 재생)
- Firebase를 이용하여 serverless 환경에서 앱 개발 ( Authentication, Firestore, Storage, Cloud Functions)
- Firebase Cloud Messaging을 이용한 푸시 노티피케이션 전송
firebase
- react native firebase 설정
- rnfirebase
npm install --save @react-native-firebase/app @react-native-firebase/auth @react-native-firebase/firestore
react-navigation 패키지 설치
npm install --save @react-navigation/native-stack @react-navigation/native react-native-screens react-native-safe-area-context
회원가입 페이지 구현
- 형식 확인하기 . validator 사용
- npm install validator
- npm install @types/validator –save
1 2 3
if (!validator.isEmail(email)) { return " 올바른 이메일이 아닙니다."; }
- SignupScreen
- name,email,password 확인 및 경고문 ```
<TextInput value={password} style={styles.input} secureTextEntry //비밀번호 입력 시 가려주는역할 onChangeText={onChangePasswordText} /> ```
firestore 설정
- authentication, firestore database 설정
채팅방
npm i lodash
npm i @types/lodash --save
await firestore().collection(Collections.CHATS)
.add
는 문서 상관없이 추가.doc
는 문서명 따로 설정 가능
useChat hook 작성
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
import { useCallback, useEffect, useState } from 'react'; import firestore from '@react-native-firebase/firestore'; import _ from 'lodash'; import { Chat, Collections, User } from '../types'; // userIds를 받아서 규칙에 따라 lodash를 사용하여 정렬해주는 함수 const getChatKey = (userIds: string[]) => { // userId값을 오름차순으로 정렬하는 것 return _.orderBy(userIds, userId => userId, 'asc'); }; // 사용자가 포함된 채팅방이 있다면 불러오고, 없다면 새로생성 const useChat = (userIds: string[]) => { const [chat, setChat] = useState<Chat | null>(null); const [loadingChat, setLoadingChat] = useState(false); const loadChat = useCallback(async () => { try { setLoadingChat(true); // userIds랑 우리가 준 userIds가 같은 채팅방이 생성되어있는지 체크 const chatSnapshot = await firestore() .collection(Collections.CHATS) .where('userIds', '==', getChatKey(userIds)) .get(); if (chatSnapshot.docs.length > 0) { const doc = chatSnapshot.docs[0]; setChat({ id: doc.id, userIds: doc.data().userIds as string[], users: doc.data().users as User[], }); return; } // userId에 userIds가 포함된 데이터만 가져오게됨. const usersSnapshot = await firestore() .collection(Collections.USERS) .where('userId', 'in', userIds) .get(); const users = usersSnapshot.docs.map(doc => doc.data() as User); const data = { userIds: getChatKey(userIds), users, }; const doc = await firestore().collection(Collections.CHATS).add(data); setChat({ id: doc.id, ...data, }); } finally { setLoadingChat(false); } }, [userIds]); useEffect(() => { loadChat(); }, [loadChat]); return { chat, loadingChat, }; }; export default useChat;
메시지 보내기 및 불러오기 기능
- firestore의 subcollection을 이용해서 메시지 전송 기능 구현
subcollection
- 메시지 chats 컬렉션의 유저목록과 분리하여야함
- chat다큐먼트 collection안에 subcollection을 만들어서 사용 가능
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// 서브컬렉션 열어주어 저장하기 const doc = await firestore() .collection(Collections.CHATS) .doc(chat.id) .collection(Collections.MESSAGES) .add(data); // 이전 메시지 + 새로운 메시지 업데이트 setMessages((prevMessages) => [ { id: doc.id, ...data, }, ].concat(prevMessages) );
메시지 불러오기
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
const loadMessages = useCallback(async (chatId: string) => { try { setLoadingMessages(true); const messagesSnapshot = await firestore() .collection(Collections.CHATS) .doc(chatId) .collection(Collections.MESSAGES) .orderBy("createdAt", "desc") .get(); const ms = messagesSnapshot.docs.map<Message>((doc) => { const data = doc.data(); return { id: doc.id, user: data.user, text: data.text, createdAt: data.createdAt.toDate(), //db의 date를 date타입으로 바꿔주기 위해 toDate()사용 }; }); setMessages(ms); } finally { setLoadingMessages(false); } }, []); useEffect(() => { if (chat?.id != null) { loadMessages(chat.id); } }, [chat?.id, loadMessages]);
채팅 메시지 구현
- inverted FlatList
- inverted 옵션을 주면 스크롤을 윗방향으로 할 수 있음
1 2 3 4 5 6 7 8 9 10 11 12 13 14
<FlatList inverted style={styles.messageList} data={messages} renderItem={({ item: message }) => { return ( <View> <Text>{message.user.name}</Text> <Text>{message.text}</Text> <Text>{message.createdAt.toISOString()}</Text> </View> ); }} />
- inverted 옵션을 주면 스크롤을 윗방향으로 할 수 있음
시간 출력
npm i moment
- 포맷 형식 작성해주면됨
1
<Text style={styles.timeText}>{moment(createdAt).format("HH:mm")}</Text>
실시간 새로운 메시지 받기 구현
- firestore 사이트 참고
onSnapshot()
메소드 사용하기1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
firestore() .collection(Collections.CHATS) .doc(chat.id) .collection(Collections.MESSAGES) .orderBy("createdAt", "desc") .onSnapshot((snapshot) => { // 메시지가 추가될때만 내용변경 const newMessages = snapshot .docChanges() .filter(({ type }) => type === "added") .map((docChange) => { const { doc } = docChange; const docData = doc.data(); const newMessage: Message = { id: doc.id, text: docData.text, user: docData.user, createdAt: docData.createdAt.toDate(), }; return newMessage; }); addNewMessage(newMessages); });
lodash → uniqBy
새로운 메시지가 중복되더라도 같은메시지가 있으면 lodash uniqBy로 제거
1 2 3 4 5
const addNewMessage = useCallback((newMessages: Message[]) => { setMessages((prevMessages) => { return _.uniqBy(newMessages.concat(prevMessages), (m) => m.id); }); }, []);
프로필 이미지 등록
- 이미지 크롭 라이브러리 이용
- react-native-image-crop-picker
- 위 사이트를 참고하여 ios와 android 셋팅하기
1 2 3 4 5 6
const onPressProfile = useCallback(async () => { const image = await ImageCropPicker.openPicker({ cropping: true, cropperCircleOverlay: true, //원모양으로 사진이 잘림 }); }, []);
- firebase storage에 이미지 업로드
- firebase storage 시작 및 패키지 설치
npm i @react-native-firebase/storage
- firebase 관련 패키지 버전을 똑같이 맞춰주어야함
1 2 3 4
"@react-native-firebase/app": "^17.4.2", "@react-native-firebase/auth": "^17.4.2", "@react-native-firebase/firestore": "^17.4.2", "@react-native-firebase/storage": "^17.4.2",
- 프로필 보여주기 및 확대기능
- react-native-image-viewing
메시지 읽음 표시 구현
- firestore server timestamp이용
- 마지막으로 채팅방에 들어온 시간 chat db에 기록
- 시간을 비교
- 메시지 전송시간 > 채팅방에 들어온 시간 : 안읽음
- 메시지 전송시간 ≤ 채팅방에 들어온 시간 : 읽음
- firestore 실시간 업데이트를 이용해서 메시지 읽은 사용자 정보 실시간으로 가져오기
- 메시지를 읽지 않은 사람 수 표시
음성메시지 전송
react-native-audio-recorder-player
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import React, { useCallback, useRef, useState } from "react";
import { PermissionsAndroid, Platform, StyleSheet } from "react-native";
import AudioRecorderPlayer, {
AVEncodingOption,
AudioEncoderAndroidType,
} from "react-native-audio-recorder-player";
const MicButton = () => {
const [recording, setRecording] = useState(false);
const audioRecorderPlayerRef = useRef(new AudioRecorderPlayer());
const startRecord = useCallback(async () => {
// android permission
if (Platform.OS === "android") {
const grants = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE,
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
]);
const granted =
grants[PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE] ===
PermissionsAndroid.RESULTS.GRANTED &&
grants[PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE] ===
PermissionsAndroid.RESULTS.GRANTED &&
grants[PermissionsAndroid.PERMISSIONS.RECORD_AUDIO] ===
PermissionsAndroid.RESULTS.GRANTED;
if (!granted) {
return;
}
}
//녹음실행
await audioRecorderPlayerRef.current.startRecorder(undefined, {
// android와 ios 오디오타입 통일
AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
AVFormatIDKeyIOS: AVEncodingOption.aac,
});
audioRecorderPlayerRef.current.addRecordBackListener(() => {});
setRecording(true);
}, []);
const stopRecord = useCallback(async () => {
const uri = await audioRecorderPlayerRef.current.stopRecorder();
audioRecorderPlayerRef.current.removeRecordBackListener();
setRecording(false);
}, []);
};
export default MicButton;
- 마이크 권한 요청
- 음성 녹음 기능 구현
- android는 npm run android로 실행시 녹음이 안되므로 android studio에서 열어야함.
- open으로 프로젝트의 android폴더를 open → (tools)device manager → 재생
- emulator의 점세개(extended controls) 클릭 → microphone → virtual microphone uses host audio input 활성화
- firebase storage에 음성 업로드하기
- 음성 메시지 전송하기
- 음성 메시지 재생 및 멈춤 기능 구현
- 남은 재생시간 표시
react-native-firebase-@messaging
- ios
- 실제 디바이스, apple 개발자 등록이 필요함
- android
- android 에뮬레이터나 실제 디바이
- FCM(Firebase Cloud Messaging)
- 비용없이 Server-to-Device, Device-to-Device로 알림을 전송할 수 있는 플랫폼
- 알림 수신 시 앱 상태
- Foreground : 앱이 실행되고 현재 보여지는 상태
- Background : 앱은 실행되었으나 최소화 되어있는 상태
- Quit : 앱이 완전히 종료된 상태
- 메시지 타입에 따른 알림표시
- Foreground : 알림표시 불가능
- Background: Notification, Notification + Data 알림표시가능
- Quit : Notification, Notification + Data 알림표시가능
- 핸드폰에서 변동사항이 firebase로 들어감 → cloud functions에서 다른 사용자에게 전송
https://rnfirebase.io/messaging/usage
yarn add @react-native-firebase/messaging
- 다른 firestore 라이브러리들과 버전 똑같이 맞추어주기 → yarn
- ios > rm -rf Pods 로 지워준 후, pod install
- 알림권한 요청 (ios, android 13+ : 13버전이전은 이미권한이 되어있음)
- android 13버전이상을 위한 패키지
- react-native-permissions
yarn add react-native-permissions
- https://github.com/zoontek/react-native-permissions/releases/tag/3.5.0
- android 13버전이상을 위한 패키지
- Push Notification 전송을 위해 fcm토큰 등록하기
메시지 도착 실시간 알림
백엔드
firebase cloud functions 프로젝트 초기화 https://firebase.google.com/docs/functions?hl=ko
https://firebase.google.com/docs/functions/firestore-events?hl=ko
cloud functions을 사용하기 위해서는 firebase무료(spark)요금제로는 사용 불가능 → 사용할수록 요금이 부과되는 Blaze요금제를 사용해야 함
- firestore trigger이용시 문서가 업데이트,생성,삭제 될때 서버에서 실행될 수 있는 코드 구현가능
- 사용
npm install -g firebase-tools
https://firebase.google.com/docs/functions/get-started?hl=ko 링크 참고하여 프로젝트 setting- login → functions기능선택 → 기존 프로젝트 선택 → 사용언어선택
- 패키지 설치나 실행시 ChatAppServer > functions 폴더 내에서 작업
- npm i eslint-config-prettier -s
- eslint와 prettier가 충돌할 때 (4칸띄울지 2칸띄울지 등) 한가지를 선택하는 패키지
- .eslint.rc파일에서 다음내용추가
1 2 3
extends: [ "prettier", ],
npm i firebase-admin
- function에서 문서를 읽기위해 사용
- 서버에서 firestore 실시간 업데이트 수신하기
- 새로운 메시지 생성 시 Push Notification 전송
- npm run deploy
- 서버에 배포가 됨. 약 3분소요
- firebase → 프로젝트 → functions → 함수의 점3개 → 로그보기 에서 잘보내지는지 확인가능
- npm run deploy
앱
- 메시지 수신 시 Notification 보여주기
- Notification 터치하면 채팅 스크린으로 이동
- background에 있는경우와 quit상태에 있는경우 다르게 처리해주어야함.
- foreground에서 알림띄우기
- messaging의 onMessage사용
- react-native-toast-message 패키지 사용하여 앱 내에서 알림띄우기
- react-native-toast-message
- app.tsx에
컴포넌트 추가
- ios APNs(Apple Push Notifications service)설정
- ios Messaging setup
- https://rnfirebase.io/messaging/usage/ios-setup
- capabilities 추가
- APNs 키 등록
- developer.apple.com
- 프로그램 리소스 → 키 등록 → 다운로드(재다운로드불가능)
- firebase 프로젝트 설정 → 클라우드 메시징 → APN인증 키 등록 (키id, 팀id-이름옆 코드), 키 파일 등록