React Native X AWS 的 APP 初體驗(上)

web 仔想踏入 APP 領域瞧瞧的第一步

Posted by YuKai on Friday, June 21, 2024

前言

這其實是學校的資料庫課程,需要一個實際應用要能對資料庫操作,並有一個簡單的操作畫面。不過,開發者之魂上身的我,決定作死開發 APP,並串接雲端的資料庫(因為我有修另一堂雲端的課,想說這樣可以兩邊都用),誰在跟你走地端,我要來個降維打擊你各位。但畢竟沒有完整得 APP 開發經驗(硬要說的話高中科展有寫過簡單的 Android Studio,但跟垃圾沒兩樣,而且也不知道自己在寫什麼),因此想說先以網頁開發的經驗去寫 APP,藉此對 APP 的架構能有初步的認識,所以選擇 React Native 作為前端開發框架,來過度這段開發時期。

至於雲端的部分,則是將後端部屬在 AWS EC2 上,並嘗試使用 RDS 和 S3 兩個 serverless 的服務,減少在地端架設伺服器的工作。

也就是說這一次前端與後端都用到新的技術,但由於之前有 React 的經驗,所以覺得 React Native 應該不會難以上手,最難以捉摸的應該就是 AWS 的部分了,另外,整個開發流程以 Typescript 作為主要語言,藉此在每一層的資料轉換時更能確定需要提供什麼類型的資料。

將分為三個部分講解,分別為前端建設、後端建設、AWS 服務串接。而這篇只講解前端的部分,後端於下篇解說。

Frontend Build

Use Expo To Build APP

使用 Expo 來搭建 React Native,Expo 提供許多工具來為 React Native 測試,使用如下命令初始化一個 React Native 專案

npx create-expo-app@latest

接著就可以使用以下指令來 Build APP,更詳細的指令會提供在初始化後的 package.json 中的 script

npx expo start

要注意的是測試 Android 時,要開啟 Android Simulator,Expo 才找得到模擬器進行建構,否則會跳出找不到裝置錯誤 image 又或者這類錯誤,是說你的模擬器還沒完全開機 image

另外如果要重新載 APP,就按下 r 就可以了,但要注意的是 Android Studio Simulator 很吃電腦效能,當機是正常不過了,這時候只要將模擬器重開就好,而且如果有使用到環境變數,直接修改環境變數是不會被重載捕捉到的,所以也要重新用 Expo 重新建構一次。

那如果要在真實裝置上使用,需要做到三件事,但僅供參考:

  1. 將防火牆新增輸入規則:Window 的做法是 winodw defender > 進階設定 > 新增輸入規則。接者將 Expo 預設的 8081 port 打開就行了
  2. 與裝置使用同個網路
  3. 安裝 expo 的反向代理功能,並開啟 tunnel 連接
npm i -g @expo/ngrok
npx expo start --tunnel

如果重載 APP 還是沒有變化,可能是真實裝置還在上一個版本,這時只要將裝置清理或重起來重新下載新版本就可以了。

How To Figure Out Axios

如果是使用 axios 發送 Request,在真實裝置上會因為不是使用 SSL/TLS 傳輸,導致被阻擋發送,這時只要在 app.json 新增這項規則就可以了,這是另外安裝 expo-build-properties 這個插件來調整 expo build 的參數,這樣就能使得 android 使用 http 方式發送,但還是建議將 SSL 加入,減少安全疑慮。

"plugins": [
      [
        "expo-build-properties",
        {
          "android": {
            "usesCleartextTraffic": true
          }
        }]
    ],

另外一個問題是不能用 localhost 進行發送,這是因為這不是在電腦地端上,所以必須找到電腦的 ip,才能成功請求到地端的 API,如果是在雲端就是雲端 Ec2 的 ip。

Time to Write React Native

基本上 RN 的寫法跟 React 一樣,狀態變化渲染可以用 useState,監測狀態變化產生 side effect 可以用 useEffect,要學習的部分就是怎麼應用 RN 的 Component,以及習慣 RN 的 style 怎麼寫,這次主要使用到這幾個:

  • View
  • Text
  • Image
  • Pressable
  • ActivityIndicator

不過 Style 的部分基本上就是 css 的屬性拿過來改,所以不難理解。

View、Text

在初始化架構中會發現來自 Themd 這個檔案,而這提供許多風格化設計的 Componet,也就是對 Componet 再次包裝,如下可以發現多了 lightColor、darkColor 兩個不同風格時的顏色選擇,因此我們可以透過這種手法來自定義我們需要的 Component,再放入畫面來減少程式碼的重複性。

type ThemeProps = {
  lightColor?: string;
  darkColor?: string;
};

export type ViewProps = ThemeProps & DefaultView['props'];

export function View(props: ViewProps) {
  const { style, lightColor, darkColor, ...otherProps } = props;
  const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');

  return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
}

因此我自定義了一個 Loading 元件,並且可以修改其外部樣式

export function Loading(props: DefaultView['props'] ) {
  return (
    <View style={props.style}>
      <ActivityIndicator size="small" color="#FFD52D" />
    </View>
  )
}

具體使用如下,這行的意思是 isLoading 如果為 true 就渲染後面的元件並套用 mainCard 這個 style。

{ isLoading && <Loading style={styles.mainCard} /> } 

Pressable

現在 RN 支援的按鈕觸發元件是 Pressable,這我覺得是 RN 想要捨棄之前 TouchableOpacity,TouchableHighlight、TouchableWithoutFeedback,在這個舊時期如果你想要改變按鈕的透明度,就要使用第一個,如果想要標式按鈕,就要用第二個,元件種類太多,因此直接將決定權和更高的彈性給使用者去設計按鈕的各階段變化,所以現在我可以透過 pressIn、pressOut,來決定我要什麼樣的設計或後續邏輯。如下:

 <Pressable 
    onPress={() => handleDeleteProduct()}
    onPressIn={() => setPressToDelete(true)}
    onPressOut={() => setPressToDelete(false)}
    style={(pressToDelete ? styles.pressInDeleteButton : styles.pressOutDeleteButton)}
>
    <Text style={{fontSize: 20, fontWeight: '700', color: '#FFBC0A', textAlign: 'center'}}>
      {pressToDelete ? "此商品成功刪除" : "刪除此商品"}
    </Text>
</Pressable>

以這個元件為例,我可以透過按件去改變 pressToDelete 的狀態來變化按鈕上的文字、樣式,即按下會顯示 “此商品成功刪除”,放開會顯示 “刪除此商品”,另外當按下去那刻也會觸發 handleDeleteProduct。

Image

這個元件重點在於要如何將圖片資料傳送進去,也就是藉由 source 這個 prop,但如果是直接放入就需要是轉換成 byte Array 的形式才能顯示,如果是 url 或者 base64 image,就要將其包裹成一個 object 再傳入解析

<Image style={{width: 80, height: 80}} source={{uri: props.item.img}} />

我個人比較偏愛這個方法,另外但如果是打包的靜態資料,就要將其引入之後直接作為 source prop value

<Image
    style={styles.tinyLogo}
    source={require('@expo/snack-static/react-native-logo.png')}
/>

Style

style 的部分可以透過 stylesheet 來建立一個 style 物件,並作為模組傳入所需要的元件

import { StyleSheet } from "react-native";

const styles = StyleSheet.create({
    mainCard: {
        alignItems: 'flex-start',
        backgroundColor: "#FFF",
        width: '90%',
        height: 'auto',
        marginLeft: 20,
        borderRadius: 10,
        zIndex: 2,
        padding: 20,
        marginBottom: 20,
    }
}

export default styles;

對於 style prop 可接受的值,可以是單獨的 style,也可以是多個 style,只是要注意後面樣式會覆蓋前面樣式

<Text style={[styles.cardTitle, {marginBottom: 0}]}>{item.name}</Text>

如同上述例子,會發現後面又有一個 marginBottom,所以會覆蓋掉 cardTitle 定義的 marginBottom。

而且還可以做邏輯判斷,如下

<Pressable onPress={handleSubmit} style={pressed ? styles.pressOutButton : styles.pressInButton}>
    <Text style={{fontSize: 20, fontWeight: '700', color: '#FFF', textAlign: 'center'}}>{pressed ? "此商品成功送出" : "新增此商品"}</Text>
</Pressable>

不過要在 prop 作邏輯判斷建議還是要用(括號)包裹起來

Details

剩下一些小細節,但我認為這些是應該歸究於開發 react 的細節

  1. 要將多個元件用 <></> 包裹成單個元件:反正就先起手式 <></>
  2. 用 map 動態渲染,會需要 unique key prop,記得先將 Componet 拉出來在引用進去,這樣才不會有複數節點導致 key 需要每個 Componet 都要設

Bad Design

const TimeBasedView = (props: {
  time: string
  data: {[key: string]: orderType[]},
}) => {
  return (
    <>
      <View style={{width: '100%', backgroundColor: '#F1F1F1'}}>
        <Text style={styles.listTime}>{props.time}</Text>
      </View>
      {props.data[props.time].map((item, index) => (
        <View style={[styles.listCard, (props.index === 0 && styles.listFirstCard)]}>
          <Text style={styles.listCardText}>訂單編號:{props.item.id}</Text>
          <Text style={styles.listCardText}>訂單人:{props.item.customer}</Text>
          <Text
            onPress={() => navigation.navigate({name: 'Details', params: {id: props.item.id}} as never)}
            style={{fontWeight: 'bold', padding: 2, paddingTop: 8}}
          >點擊進行查看 </Text>
        </View>
      ))}
    </>
  )
}

Good Design

const TimeBasedView = (props: {
  time: string
  data: {[key: string]: orderType[]},
}) => {
  return (
    <>
      <View style={{width: '100%', backgroundColor: '#F1F1F1'}}>
        <Text style={styles.listTime}>{props.time}</Text>
      </View>
      {props.data[props.time].map((item, index) => (
        <OrderView key={index} index={index} item={item} />
      ))}
    </>
  )
}

const OrderView = (props: {
  index: number,
  item: orderType,
}) => {
  const navigation = useNavigation();
  return (
    <View style={[styles.listCard, (props.index === 0 && styles.listFirstCard)]}>
      <Text style={styles.listCardText}>訂單編號:{props.item.id}</Text>
      <Text style={styles.listCardText}>訂單人:{props.item.customer}</Text>
      <Text
        onPress={() => navigation.navigate({name: 'Details', params: {id: props.item.id}} as never)}
        style={{fontWeight: 'bold', padding: 2, paddingTop: 8}}
      >點擊進行查看 </Text>
    </View>
  )
}

Use React Navigation To Control Screen

我另外使用 React Natigtion 來作導航控制,主要是 Expo 提供的元件太少,要如何佈局可以參考官方的例子,這裡紀錄三點好用的功能

導向攜帶參數

基本上我覺得就跟 Web 的 Route 是一樣的概念,可以將參數放進 params 在傳遞給指定的元件。

import { useNavigation, NavigationProp } from '@react-navigation/native';
const navigation = useNavigation<NavigationProp<OrderRootStackParamList>>();
const handleSubmitOrder = () => {
    navigation.navigate("OrderSetUp", { orderItems });
};

這樣我就可以導向到 OrderSetup 的 Component,並使用 orderItems Object

// In OrderSetup Componetnt
import { useRoute } from "@react-navigation/native";
const route = useRoute<OrderSetUpRouteProp>();
const { orderItems } = route.params;

可以透過 useRoute 來解析出傳過來的參數,就不用在定義 prop 抽絲剝繭出傳過來的參數

畫面重新渲染

當我們需要將畫面重新渲染的時候,可以使用 useIsFocused 來判斷此 Componet 是否出現在畫面上,並搭配 useEffect 監測這個值來重新渲染畫面,如下:

import { useIsFocused } from "@react-navigation/native";
const isFocused = useIsFocused();
useEffect(() => {
    ...
  }, [isFocused]);

等待 Component 生成再導向

如果直接導向到一個還沒有被建立的元件,會導致導向錯誤,雖然還是有機率成功,但其實可以建立一個 Ref 捕抓 NavigationContainer 元件的狀態再進行導向會穩定許多。

import {
  NavigationContainer,
  createNavigationContainerRef,
} from "@react-navigation/native";

const navigationRef = createNavigationContainerRef();
const navigate = (name: string, params?: any) => {
  if (navigationRef.isReady()) {
    navigationRef.navigate({ name, params } as never);
  }
};

// Component
<NativeBaseProvider>
    <NavigationContainer
        ref={navigationRef}
        linking={linking}
        independent={true}
    >
        <RootLayoutNav />
    </NavigationContainer>
</NativeBaseProvider>

Use Redux to Identify Role

由於我會在不同深度的 componet 需要 user 的資料,所以我設計登入之後會將資料儲存在 Redux Store,配置如下:

import { createSlice, configureStore } from "@reduxjs/toolkit";

export interface State {
  role: string;
  id: string;
  name: string;
  address: string;
  phone: string;
}

const roleSlice = createSlice({
  name: "roleAuth",
  initialState: {
    role: "",
    id: "",
    name: "",
    address: "",
    phone: "",
  },
  reducers: {
    userLogin: (state, action) => {
      state.role = "user";
      state.id = action.payload.id;
      state.name = action.payload.name;
      (state.address = action.payload.address),
        (state.phone = action.payload.phone);
    },
    adminLogin: (state, action) => {
      state.role = "admin";
      state.id = action.payload.id;
      state.name = action.payload.name;
      state.address = action.payload.address;
      state.phone = action.payload.phone;
    },
    Logout: (state) => {
      state.role = "";
      state.id = "";
      state.name = "";
      state.address = "";
      state.phone = "";
    },
  },
});

export const { userLogin, adminLogin, Logout } = roleSlice.actions;

export const store = configureStore({
  reducer: roleSlice.reducer,
});

主要是透過 reducers 中定義的 function 來改變 store state,因此我可以在任何地方呼叫這些 function 來改變,方法如下:

store.dispatch(
    adminLogin({
        id: data.SID,
        name: data.SName,
        address: data.SAddress,
        phone: data.SPhone,
    })
)

另外可以藉此方法取出 store state

store.getState()

而以上是 Redux 的基本功能,但如果我今天要監聽 store 的狀態來重新渲染畫面,就會有點複雜,我是使用了 react-redux 插件中的 Provider、useSelector 來實現這個效果。

import { useSelector, Provider } from "react-redux";

...

Parent Component

return (
<Provider store={store}>
  <NativeBaseContainer />
</Provider>
)

...

Child Component

const role: string = useSelector((state: State) => state.role);
  useEffect(() => {
    if (role === "admin") navigate("AdminHome");
    else if (role === "user") navigate("setting");
    else navigate("Login");
  }, [role]);

簡單來說,透過 Provider 將 store 的狀態註冊,就能用 useSelector 取出被監聽的值,只要這個值有變化,Provider 就會重新並帶動其子元件同步渲染。具體原理請見 react 的 Context、Provider。

在這個例子不只是重新渲染,還會因為 role 的狀態變化產生副作用,也就是會導向到指定路由

另外要注意的是 Provider 與 useSelector 的深度不能太靠近,不然會抓不到 store

After Frontend

基本上前段有用到的功能都提到了,但其實還有一個 react-image-picker 的插件用來選取裝置內的圖片,但因為這個插件需要調整 APP 的權限設定,而這需要寫一個 XML,所以就得等我研究完 Android Studio 再說,也就是說現在只能在 Browser 的情況下使用選取圖片。下篇就紀錄後端的架設,採用 node-ts + express 配置,並說明如何使用 s3、rds 服務。


comments powered by Disqus