Delivery icons created by dreamicons - Flaticon
npx react-native init FoodDeliveryApp --template react-native-template-typescript
보통은 강의용으로 자동생성 안 좋아하는데 RN은 자동생성하지 않으면 네이티브단까지 처리하기 어려움
cd FoodDeliveryApp # 폴더로 이동
npm run android # 안드로이드 실행 명령어
npm run ios # 아이폰 실행 명령어
서버가 하나 뜰 것임. Metro 서버. 여기서 소스 코드를 컴파일하고 앱으로 전송해줌. 기본 8081포트.
메트로 서버가 꺼져있다면 터미널을 하나 더 열어
npm start
개발은 iOS 기준으로 하는 게 좋다(개인 경험). 그러나 강좌는 어쩔 수 없이 Windows로 한다.
react-native@0.66 버전, 한 달에 0.1씩 올라가는데 요즘 개발 속도가 느려져서 규칙이 깨짐. 거의 완성 단계라 신규 기능은 npm에서 @react-native-community로부터 받아야 함. 버전 업그레이드 함부로 하지 말 것!
[맥 전용]npx pod-install도 미리 한 번, iOS 라이브러리 받는 용도
Flipper 페이스북이 만든 모바일앱 디버거도 좋음(다만 연결 시 에러나는 사람 다수 발견)
npm i react-native-flipper redux-flipper rn-async-storage-flipper @react-native-async-storage/async-storage
npx pod-install # 아이폰 전용
\android\app\src\main\res\values\strings.xml
app.json의 displayName
\ios\FoodDeliveryApp\Info.plist의 CF
BundleDisplayName
react-router-native도 대안임(웹에서 넘어온 개발자들에게 친숙, 웹처럼 주소 기반)
npm i @react-navigation/native
npm i @react-navigation/native-stack
npm i react-native-screens react-native-safe-area-context
npx pod-install # 맥 전용
android/app/src/main/java/FoodDeliveryApp/MainActivity.java
import android.os.Bundle;
...
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
}
App.tsx 교체
import * as React from 'react';
import {NavigationContainer, ParamListBase} from '@react-navigation/native';
import {
createNativeStackNavigator,
NativeStackScreenProps,
} from '@react-navigation/native-stack';
import {Text, TouchableHighlight, View} from 'react-native';
import {useCallback} from 'react';
type RootStackParamList = {
Home: undefined;
Details: undefined;
};
type HomeScreenProps = NativeStackScreenProps<RootStackParamList, 'Home'>;
type DetailsScreenProps = NativeStackScreenProps<ParamListBase, 'Details'>;
function HomeScreen({navigation}: HomeScreenProps) {
const onClick = useCallback(() => {
navigation.navigate('Details');
}, [navigation]);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<TouchableHighlight onPress={onClick}>
<Text>Home Screen</Text>
</TouchableHighlight>
</View>
);
}
function DetailsScreen({navigation}: DetailsScreenProps) {
const onClick = useCallback(() => {
navigation.navigate('Home');
}, [navigation]);
return (
<View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
<TouchableHighlight onPress={onClick}>
<Text>Details Screen</Text>
</TouchableHighlight>
</View>
);
}
const Stack = createNativeStackNavigator();
function App() {
return (
<NavigationContainer>
<Stack.Navigator initialRouteName="Home">
<Stack.Screen
name="Home"
component={HomeScreen}
options={{title: 'Overview'}}
/>
<Stack.Screen name="Details">
{props => <DetailsScreen {...props} />}
</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
);
}
export default App;
npm install @react-navigation/bottom-tabs
App.tsx
src/components/DismissKeyBoardView.tsx
import React from 'react';
import {
TouchableWithoutFeedback,
Keyboard,
StyleProp,
ViewStyle,
KeyboardAvoidingView,
Platform,
} from 'react-native';
const DismissKeyboardView: React.FC<{ style: StyleProp<ViewStyle> }> = ({children, ...props}) => (
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<KeyboardAvoidingView
{...props}
style={props.style}
behavior={Platform.OS === 'android' ? undefined : 'padding'}>
{children}
</KeyboardAvoidingView>
</TouchableWithoutFeedback>
);
export default DismissKeyboardView;
인풋 바깥 클릭 시 키보드를 가리기 위함
npm i react-native-keyboard-aware-scrollview
types/react-native-keyboard-aware-scroll-view
src/components/DismissKeyBoardView.tsx
back 서버 실행 필요, DB 없이도 되게끔 만들어둠. 서버 재시작 시 데이터는 날아가니 주의
# 터미널 하나 더 켜서
cd back
npm start
리덕스 설정
npm i @reduxjs/toolkit react-redux redux-flipper
src/store/index.ts와 src/store/reducer.ts, src/slices/user.ts 작성
AppInner.tsx 생성 및 isLoggedIn을 redux로 교체(AppInner 분리 이유는 App.tsx에서 useSelector를 못 씀)
App.tsx
import * as React from 'react';
import {NavigationContainer} from '@react-navigation/native';
import {Provider} from 'react-redux';
import store from './src/store';
import AppInner from './AppInner';
function App() {
return (
<Provider store={store}>
<NavigationContainer>
<AppInner />
</NavigationContainer>
</Provider>
);
}
export default App;
액세스토큰/리프레시토큰을 받아서 다음 라이브러리로 저장
npm install react-native-encrypted-storage
npx pod-install # ios 전용
서버 요청은 axios 사용(요즘 ky나 got으로 넘어가는 추세이나 react-native와 호환 여부 불투명)
npm i axios
환경변수, 키 값을 저장할 config 패키지
npm i react-native-config
import Config from 'react-native-config';
-Config가 적용이 안 되면 다음 추가해야함
android/app/proguard-rules.pro
-keep class com.fooddeliveryapp.BuildConfig { *; }
android/app/build.gradle
apply plugin: "com.android.application"
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
...
defaultConfig {
...
resValue "string", "build_config_package", "com.fooddeliveryapp"
}
API_URL=http://10.0.2.2:3105
-10.0.2.2로 해야 함(localhost로 하면 안드로이드에서 안 됨)
암호화해서 저장할 데이터는 다음 패키지에
import EncryptedStorage from 'react-native-encrypted-storage';
await EncryptedStorage.setItem('키', '값');
await EncryptedStorage.removeItem('키');
const 값 = await EncryptedStorage.getItem('키');
src/pages/SignUp.tsx, src/pages/SignIn.tsx
android에서 http 요청이 안 보내지면
ActivityIndicator로 로딩창 꾸미기
웹소켓 기반 라이브러리
npm i socket.io-client
src/hooks/useSocket.ts
import {useCallback} from 'react';
import {io, Socket} from 'socket.io-client';
import Config from 'react-native-config';
let socket: Socket | undefined;
const useSocket = (): [Socket | undefined, () => void] => {
const disconnect = useCallback(() => {
if (socket) {
socket.disconnect();
socket = undefined;
}
}, []);
if (!socket) {
socket = io(`${Config.API_URL}`, {
transports: ['websocket'],
});
}
return [socket, disconnect];
};
export default useSocket;
AppInner.tsx
const [socket, disconnect] = useSocket();
useEffect(() => {
const helloCallback = (data: any) => {
console.log(data);
};
if (socket && isLoggedIn) {
console.log(socket);
socket.emit('login', 'hello');
socket.on('hello', helloCallback);
}
return () => {
if (socket) {
socket.off('hello', helloCallback);
}
};
}, [isLoggedIn, socket]);
useEffect(() => {
if (!isLoggedIn) {
console.log('!isLoggedIn', !isLoggedIn);
disconnect();
}
}, [isLoggedIn, disconnect]);
src/pages/Settings.tsx
socket.io에서 주문 내역 받아서 store에 넣기
AppInner.tsx
useEffect(() => {
const callback = (data: any) => {
console.log(data);
dispatch(orderSlice.actions.addOrder(data));
};
if (socket && isLoggedIn) {
socket.emit('acceptOrder', 'hello');
socket.on('order', callback);
}
return () => {
if (socket) {
socket.off('order', callback);
}
};
}, [isLoggedIn, socket]);
encrypted-storage에서 토큰 불러오기
AppInner.tsx
// 앱 실행 시 토큰 있으면 로그인하는 코드
useEffect(() => {
const getTokenAndRefresh = async () => {
try {
const token = await EncryptedStorage.getItem('refreshToken');
if (!token) {
return;
}
const response = await axios.post(
`${Config.API_URL}/refreshToken`,
{},
{
headers: {
authorization: `Bearer ${token}`,
},
},
);
dispatch(
userSlice.actions.setUser({
name: response.data.data.name,
email: response.data.data.email,
accessToken: response.data.data.accessToken,
}),
);
} catch (error) {
console.error(error);
if ((error as AxiosError).response?.data.code === 'expired') {
Alert.alert('알림', '다시 로그인 해주세요.');
}
}
};
getTokenAndRefresh();
}, [dispatch]);
src/slices/order.ts
src/pages/Settings.tsx
src/pages/Orders.tsx
src/components/EachOrder.tsx
axios.interceptor 설정하기
useEffect(() => {
axios.interceptors.response.use(
response => {
return response;
},
async error => {
const {
config,
response: {status},
} = error;
if (status === 419) {
if (error.response.data.code === 'expired') {
const originalRequest = config;
const refreshToken = await EncryptedStorage.getItem('refreshToken');
// token refresh 요청
const {data} = await axios.post(
`${Config.API_URL}/refreshToken`, // token refresh api
{},
{headers: {authorization: `Bearer ${refreshToken}`}},
);
// 새로운 토큰 저장
dispatch(userSlice.actions.setAccessToken(data.data.accessToken));
originalRequest.headers.authorization = `Bearer ${data.data.accessToken}`;
// 419로 요청 실패했던 요청 새로운 토큰으로 재요청
return axios(originalRequest);
}
}
return Promise.reject(error);
},
);
}, [dispatch]);
npm i react-native-nmap --force
npx pod-install # ios 전용
[ios]git-lfs로 추가 설치 필요 참고
<View
style={{
width: Dimensions.get('window').width - 30,
height: 200,
marginTop: 10,
}}>
<NaverMapView
style={{width: '100%', height: '100%'}}
zoomControl={false}
center={{
zoom: 10,
tilt: 50,
latitude: (start.latitude + end.latitude) / 2,
longitude: (start.longitude + end.longitude) / 2,
}}>
<Marker
coordinate={{
latitude: start.latitude,
longitude: start.longitude,
}}
pinColor="blue"
/>
<Path
coordinates={[
{
latitude: start.latitude,
longitude: start.longitude,
},
{latitude: end.latitude, longitude: end.longitude},
]}
/>
<Marker
coordinate={{latitude: end.latitude, longitude: end.longitude}}
/>
</NaverMapView>
</View>
권한 얻기(위치정보, 카메라, 갤러리)
npm i react-native-permissions
ios/Podfile
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy"
pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways"
pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse"
pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
ios/FoodDeliveryApp/Info.plist
<key>NSCameraUsageDescription</key>
<string>배송완료 사진 촬영을 위해 카메라 권한이 필요합니다.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSMotionUsageDescription</key>
<string>배송중 위치 확인을 위해서 위치 권한이 필요합니다.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>배송완료 사진 선택을 위해 라이브러리 접근 권한이 필요합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>배송완료 사진 선택을 위해 라이브러리 접근 권한이 필요합니다.</string>
android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE"/>
npx pod-install
src/hooks/usePermissions.ts
import {useEffect} from 'react';
import {Alert, Linking, Platform} from 'react-native';
import {check, PERMISSIONS, request, RESULTS} from 'react-native-permissions';
function usePermissions() {
// 권한 관련
useEffect(() => {
if (Platform.OS === 'android') {
check(PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION)
.then(result => {
console.log('check location', result);
if (result === RESULTS.BLOCKED || result === RESULTS.DENIED) {
Alert.alert(
'이 앱은 위치 권한 허용이 필요합니다.',
'앱 설정 화면을 열어서 항상 허용으로 바꿔주세요.',
[
{
text: '네',
onPress: () => Linking.openSettings(),
},
{
text: '아니오',
onPress: () => console.log('No Pressed'),
style: 'cancel',
},
],
);
}
})
.catch(console.error);
} else if (Platform.OS === 'ios') {
check(PERMISSIONS.IOS.LOCATION_ALWAYS)
.then(result => {
if (result === RESULTS.BLOCKED || result === RESULTS.DENIED) {
Alert.alert(
'이 앱은 백그라운드 위치 권한 허용이 필요합니다.',
'앱 설정 화면을 열어서 항상 허용으로 바꿔주세요.',
[
{
text: '네',
onPress: () => Linking.openSettings(),
},
{
text: '아니오',
onPress: () => console.log('No Pressed'),
style: 'cancel',
},
],
);
}
})
.catch(console.error);
}
if (Platform.OS === 'android') {
check(PERMISSIONS.ANDROID.CAMERA)
.then(result => {
if (result === RESULTS.DENIED || result === RESULTS.GRANTED) {
return request(PERMISSIONS.ANDROID.CAMERA);
} else {
console.log(result);
throw new Error('카메라 지원 안 함');
}
})
.catch(console.error);
} else {
check(PERMISSIONS.IOS.CAMERA)
.then(result => {
if (
result === RESULTS.DENIED ||
result === RESULTS.LIMITED ||
result === RESULTS.GRANTED
) {
return request(PERMISSIONS.IOS.CAMERA);
} else {
console.log(result);
throw new Error('카메라 지원 안 함');
}
})
.catch(console.error);
}
}, []);
}
export default usePermissions;
npm i @react-native-community/geolocation
src/pages/Ing.tsx
src/pages/Complete.tsx
이미지 선택 후 리사이징
npm i react-native-image-crop-picker
npm i react-native-image-resizer
npx pod-install # ios 전용
Native Module Patching
npm i patch-package
package.json
"scripts": {
"postinstall": "patch-package",
"android": "react-native run-android",
npx patch-package react-native-image-crop-picker
android/app/src/main/AndroidManifest.xml
...
<queries>
<package android:name="com.skt.tmap.ku" />
</queries>
</manifest>
src/pages/Ing.tsx
TMap.openNavi(
'도착지',
end.longitude.toString(),
end.latitude.toString(),
'MOTORCYCLE',
).then(data => {
console.log('TMap callback', data);
if (!data) {
Alert.alert('알림', '티맵을 설치하세요.');
}
});
npm i react-native-splash-screen
...
const token = await EncryptedStorage.getItem('refreshToken');
if (!token) {
SplashScreen.hide();
return;
}
...
} finally {
SplashScreen.hide();
}
};
getTokenAndRefresh();
}, [dispatch]);
npm i react-native-vector-icons
npm i -D @types/react-native-vector-icons
npm i react-native-fast-image
링크
src/slices/order.ts
interface InitialState {
...
completes: Order[];
}
const initialState: InitialState = {
...
completes: [],
};
...
setCompletes(state, action) {
state.completes = action.payload;
},
src/pages/Settings.tsx
푸쉬알림 보내기
npm i @react-native-firebase/analytics @react-native-firebase/app @react-native-firebase/messaging
npm i react-native-push-notification @react-native-community/push-notification-ios
npm i -D @types/react-native-push-notification
npx pod-install
[ios][따라할 것](https://github.com/react-native-push-notification/ios)
App.tsx
adb devices
adb -s <기기이름> reverse tcp:8081 tcp:8081
여러 문제 발견 가능
android/app/build.gradle
def enableSeparateBuildPerCPUArchitecture = true
/**
* Run Proguard to shrink the Java bytecode in release builds.
*/
def enableProguardInReleaseBuilds = true
package.json
"scripts": {
...
"build:android": "npm ci && cd android && ./gradlew bundleRelease && cd .. && open android/app/build/outputs/bundle/release",
"apk:android": "npm ci && cd android && ./gradlew assembleRelease && cd .. && open android/app/build/outputs/apk/release",
iOS 개발자 멤버쉽 가입 필요
버저닝, 배포 자동화 가능
npm i react-native-code-push
npm install appcenter appcenter-analytics appcenter-crashes
npm i -g appcenter-cli
appcenter login
appcenter codepush deployment list -a zerohch0/food-delivery-app-android -k
App.tsx
import codePush from "react-native-code-push";
const codePushOptions: CodePushOptions = {
checkFrequency: CodePush.CheckFrequency.MANUAL,
// 언제 업데이트를 체크하고 반영할지를 정한다.
// ON_APP_RESUME은 Background에서 Foreground로 오는 것을 의미
// ON_APP_START은 앱이 실행되는(켜지는) 순간을 의미
installMode: CodePush.InstallMode.IMMEDIATE,
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
// 업데이트를 어떻게 설치할 것인지 (IMMEDIATE는 강제설치를 의미)
};
function App() {
}
export default codePush(codePushOptions)(App);
"codepush:android": "appcenter codepush release-react -a 아이디/앱이름 -d 배포이름 --sourcemap-output --output-dir ./build -m -t 타겟버전",
"codepush:ios": "appcenter codepush release-react -a 아이디/앱이름 -d 배포이름 --sourcemap-output --output-dir ./build -m -t 타겟버전",
"bundle:android": "react-native bundle --assets-dest build/CodePush --bundle-output build/CodePush/index.android.bundle --dev false --entry-file index.js --platform android --sourcemap-output build/CodePush/index.android.bundle.map",
"bundle:ios": "react-native bundle --assets-dest build/CodePush --bundle-output build/CodePush/main.jsbundle --dev false --entry-file index.js --platform ios --sourcemap-output build/CodePush/main.jsbundle.map",
[맥 전용]ios 폴더 안에서 pod 명령어 수행 가능, but npx pod-install은 프로젝트 폴더 어디서나 가능
시작 성능 빨라지고, 메모리 사용량 적고, 앱 사이즈 작아짐
이미 메트로 서버가 다른 데서 켜져 있는 것임. 메트로 서버를 실행하고 있는 터미널 종료하기
메트로 서버 꺼볼 것
axios@0.24 설치(axios@0.25.0에 문제 있음)
링크
cd android
./gradlew clean
cd ..
npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/app/src/main/assets/index.android.bundle
android/gradle.properties에 다음 줄 추가
org.gradle.jvmargs=-XX\:MaxHeapSize\=1024m -Xmx1024m
또는
android/app/src/main/AndroidManifest.xml 에서 태그에 android:largeHeap="true" 추가
npx react-native start --reset-cache
cd android && ./gradlew clean
cd ..
npx react-native run-android
윈도에서 발생하는 에러인데 choco로 openssl 다시 설치하기
chmod 755 android/gradlew