前言
最近公司需要移植一個應用到 iOS 端,本來想要嘗試 uniapp 的新架構 uniapp-x 的,折騰兩天放棄了,選擇了 React Native。
原因:
- HbuilderX 中的 uniapp-x 模版過于臃腫,夾雜很多不需要的東西(可能是我選錯了模版)
- 文檔不清晰
- 生態還未發展
當然我本身也更喜歡 React 函數式組件的寫法,RN 的生態也足夠好。
初始化項目
使用 expo 框架來快速搭建一個基本的項目,參考他的 快速開始 文檔即可;這里推薦使用開發構建模式(Bare Workflow 裸工作流),EXPO Go 模式可能會有一些問題,而且很多開源的 RN 原生模塊不支持 Expo Go 模式。
Bare Workflow: Expo 暴露 iOS & Android 原生代碼,支持深度自定義。
一些注意事項、問題:
-
使用了帶有 import.meta 的包時報錯 https://github.com/expo/expo/issues/30323
解決方案:在項目根目錄下新建
metro.config.js
文件,寫入以下代碼:const { getDefaultConfig } = require('expo/metro-config'); const {wrapWithReanimatedMetroConfig } = require('react-native-reanimated/metro-config');const config = getDefaultConfig(__dirname);// 解決 web import.meta 問題,issue: https://github.com/expo/expo/issues/30323 config.resolver.unstable_conditionNames = ['browser','require','react-native' ];module.exports = wrapWithReanimatedMetroConfig(config);
metro 是 Expo 使用的編譯打包器。
-
進行了任何會影響原生代碼(iOS、Android 目錄)的變動,需要重新運行
npm run prebuild
來重新生成原生代碼,比如修改了app.json
、添加了原生依賴。 -
如果清理了一些原生依賴或會被包含到原生代碼中的資源,需要運行
npm run prebuild --clean
來重新生成一個干凈的 iOS、Android 文件夾,npm run prebuild
是增量的,不會去除未使用的依賴代碼、資源。注意:開發過程中可能會經常直接修改原生代碼,運行
npm run prebuild --clean
后這些變動會被刪除,注意經常使用 Git 提交代碼,方便還原。 -
如果你使用 pnpm,由于 pnpm 軟鏈接的原因,Expo 可能找不到一些依賴 https://github.com/expo/eas-cli/issues/2789, 你需要添加以下內容到
.npmrc
文件里:public-hoist-pattern[]=*expo-modules-autolinking public-hoist-pattern[]=*expo-modules-core public-hoist-pattern[]=*babel-preset-expo
架構與各項功能的實現
網絡請求
由于 RN 在底層實現了 fetch API,所以 axios 在 RN 中是可用的,可以像往常一樣使用:
import axios from 'axios';const http = axios.create({baseURL: process.env.EXPO_PUBLIC_BASE_API_URL, // RN & Expo 支持環境變量timeout: 30 * 1000
});http.interceptors.request.use((req) => req,(error) => Promise.reject(error)
);http.interceptors.response.use((response: AxiosResponse<BaseResponse>) => response.data
);export default http;
如果 iOS 與 Android 需要支持 http 請求,需要執行額外設置:
-
iOS
// app.json {"expo": {"ios": {"infoPlist": {"NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true }}}} }
-
Android
新建
android/app/src/main/res/xml/network_security_config.xml
文件,寫入以下內容:<?xml version="1.0" encoding="utf-8"?> <network-security-config><base-config cleartextTrafficPermitted="true" /> </network-security-config>
在
AndroidManifest.xml
的application
塊中寫入內容android:networkSecurityConfig="@xml/network_security_config"
。
組件庫
目前項目使用的 React Native UI,一開始是看他最近一年還有維護,但后續發現該項目在找接任者,且部分組件本身還有一些 bug,后續可能需要替換。
目前發現的一些 bug:
-
BottomSheet 在 iOS 上的高度問題,具體表現為首次彈出時不會添加安全區域的 Padding,而后續彈出時卻添加了,會導致我們的 UI 異常,目前通過封裝解決:
import type { BottomSheetProps } from '@rneui/themed'; import { BottomSheet } from '@rneui/themed'; import { useCallback, useMemo, useRef, useState } from 'react'; import type { LayoutChangeEvent } from 'react-native'; import { Pressable, StyleSheet, View } from 'react-native'; import {useSafeAreaFrame,useSafeAreaInsets } from 'react-native-safe-area-context';export interface FixBottomSheetProps extends BottomSheetProps {children?: React.ReactNode; }const FIXED_PROPS = {statusBarTranslucent: true,transparent: true,hardwareAccelerated: true };export function FixBottomSheet(props: FixBottomSheetProps) {const {isVisible = false,children,modalProps = {},onBackdropPress,...rest} = props;const rect = useSafeAreaFrame();const insets = useSafeAreaInsets();/** 修復底部導航欄遮擋問題 */const [areaHeight, setAreaHeight] = useState(rect.height + insets.top + insets.bottom);const onBackdropPressRef = useRef(onBackdropPress);onBackdropPressRef.current = onBackdropPress;const scrollViewProps = useMemo(() => ({style: styles.full,contentContainerStyle: styles.full,// 禁用橡皮筋效果bounces: false,onLayout(event: LayoutChangeEvent) {setAreaHeight(event.nativeEvent.layout.height + insets.top + insets.bottom);}}),[insets.bottom, insets.top]);const handleOnBackdropPress = useCallback(() => {onBackdropPressRef.current?.();}, []);return (<BottomSheet{...rest}isVisible={isVisible}onBackdropPress={handleOnBackdropPress}scrollViewProps={scrollViewProps as any}modalProps={{...modalProps,...FIXED_PROPS}}>{/* fix ios top */}<Viewstyle={{height: Math.max(0, areaHeight - rect.height - insets.bottom)}}></View><Pressable style={styles.container} onPress={handleOnBackdropPress}><Pressable pointerEvents={'box-none'}>{children}</Pressable></Pressable>{/* fix ios bottom */}<Viewstyle={{ height: Math.max(0, areaHeight - rect.height - insets.top) }}></View></BottomSheet>); }const styles = StyleSheet.create({full: {width: '100%',height: '100%'},container: {flex: 1,justifyContent: 'flex-end'} });
關于 Model:BottomSheet 是基于 RN 的 Model 組件封裝的,Model 內的視圖不在 Root View 之內,所以一些 Provider 會失效,需要在 Model 內部在包含一個 Provider。
此外,由于 iOS 平臺的策略,不允許同時彈出兩個 Model,在彈出第二個時,需要將第一個關閉。
-
Tab & TabView 在快速滑動時會偶現滾出屏外,暫無解決方案,可能需要替換組件。
項目里復雜 UI 較少,沒有深度使用 React Native UI,目前來說夠用,也支持深色模式。
狀態管理
狀態管理使用 zustand 即可,如果需要持久化,他也支持自定義 storage 的實現,示例:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { omit } from 'lodash-es';
import AsyncStorage from '@react-native-async-storage/async-storage';async function setItem<T = string>(key: StorageKeys, value: T) {let transfered = '';try {if (typeof value !== 'string') {transfered = JSON.stringify(value);}await AsyncStorage.setItem(key, transfered);} catch (_err) {console.error('storage-setItem', _err);}
}async function getItem<T = string>(key: StorageKeys): Promise<T | null> {let value: string | null = null;try {value = await AsyncStorage.getItem(key);return value === null ? value : JSON.parse(value);} catch (_err) {console.error('storage-getItem', _err);return value as T | null;}
}function removeItem(key: StorageKeys) {try {return AsyncStorage.removeItem(key);} catch (_err) {console.error('storage-removeItem', _err);}
}const storage = {getItem: getItem,setItem: setItem,removeItem: removeItem
};// ---------interface StoreLoginState {userNo: string;password: string;rememberMe: boolean;
}interface StoreLoginAction {setLogin(body: StoreLoginState): void;reset(): void;
}export const useLogin = create<StoreLoginState & StoreLoginAction>()(persist((set) => ({userNo: '',password: '',rememberMe: true,setLogin: (body) => {set((state) => ({ ...state, ...body }));},reset: () => {set({ userNo: '', password: '', rememberMe: true });}}),{name: 'login',storage: stroage,partialize(state) {return omit(state, ['setLogin', 'reset']);}})
);
字體
在二次封裝 TextInput 組件時,發現 placeholder 的文本始終無法居中對齊,后來發現是默認字體的原因,于是使用了阿里巴巴普惠體替換了默認字體。
使用 expo-font 加載字體即可,這里建議文件名以下劃線分割,否則 iOS 端可能不生效,具體可看 https://docs.expo.dev/versions/latest/sdk/font/
全局 Provider
項目中,難免會需要封裝一些全局組件 & Provider 的時候,方便使用,只需要在根組件包裹自己的 Provider 即可:
import type { OverlayProps } from '@rneui/themed';
import {createContext,memo,useContext,useMemo,useRef,useState
} from 'react';
import { FreezeComp } from '../base/FreezeComp';interface AlertProviderProps {children?: React.ReactNode;
}export interface AlertBoxOptions {title: string;type: StatusType;message: string;cancelText?: string;confirmText?: string;showCancel?: boolean;confirm?(): void;cancel?(): void;
}type AlertBoxProps = {options?: AlertBoxOptions;reset(value?: undefined | never): void;
} & Omit<OverlayProps, 'children' | 'isVisible'>;type AlertBoxObject = {alertBox(options: AlertBoxOptions): void;
};const AlertProvierContext = createContext<AlertBoxObject>({} as AlertBoxObject);export const AlertProvider = memo(function AlertProvider(props: AlertProviderProps
) {const { children } = props;const [options, setOptions] = useState<AlertBoxOptions>();const visibleRef = useRef(!!options);if (!!options && !visibleRef.current) {visibleRef.current = true;}const alertBoxObject = useMemo<AlertBoxObject>(() => ({ alertBox: (options) => setOptions(options) }),[]);return (<><AlertProvierContext.Provider value={alertBoxObject}><FreezeComp>{children}</FreezeComp></AlertProvierContext.Provider>{visibleRef.current ? (<AlertBox options={options} reset={setOptions}></AlertBox>) : null}</>);
});const AlertBox = memo(function AlertBox(props: AlertBoxProps) {return <></>;
});export function useAlertBox() {return useContext(AlertProvierContext);
}
由于是頂層組件,重渲染會影響所有子孫組件,所以需要避免多余的渲染,同時子孫組件也要做好緩存。
Viewer 全局預覽組件
項目里可能會大量用到預覽功能,包括圖片、PDF 等,于是封裝了下面的 Provider(部分代碼):
import { useThemeColor } from '@/hooks/theme/useThemeColor';
import i18n from '@/locales';
import { AntDesign } from '@expo/vector-icons';
import { Zoomable } from '@likashefqet/react-native-image-zoom';
import type { ListRenderItem } from '@shopify/flash-list';
import { FlashList } from '@shopify/flash-list';
import React, {createContext,memo,useCallback,useContext,useEffect,useMemo,useRef,useState
} from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { Platform, StyleSheet, useWindowDimensions, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { FixBottomSheet } from '../base/FixBottomSheet';
import { FreezeComp } from '../base/FreezeComp';
import { ScaleImage } from '../base/ScaleImage';
import { ScalePdf } from '../base/ScalePdf';
import { ThemedText } from '../base/ThemedText';
import { WrapPressEffect } from '../base/WrapPressEffect';export interface UriItem {uri: string;type?: string;
}export interface ViewerOptions<T extends UriItem> {data: T[];selectedIndex?: number;onClose?(): any;
}export interface ViewerContextType {showViewer<T extends UriItem>(options: Omit<ViewerProps<T>, 'isVisible'>): void;
}interface ViewerProps<T extends UriItem> {isVisible: boolean;data: T[];selectedIndex?: number;onClose?(): void;
}interface ViewerProviderProps {children: React.ReactNode;
}const ViewerContext = createContext({} as ViewerContextType);interface ViewerItem {item: UriItem;maxWidth: number;maxHeight: number;setScrollEnable(enable: boolean): void;
}function ViewerImage(props: ViewerItem) {return (<Zoomablestyle={styles.wrapper}minScale={0.6}maxScale={3}doubleTapScale={3}isDoubleTapEnabled><ScaleImageshowErrorTextresize={{ maxWidth: props.maxWidth, maxHeight: props.maxHeight }}source={{ uri: props.item.uri }}contentFit="cover"/></Zoomable>);
}function ViewerPdf(props: ViewerItem) {const { setScrollEnable } = props;const [scale, setScale] = useState(1);const lastTouchRef = useRef({ x: 0, y: 0 });return (<ViewonTouchStart={Platform.OS === 'android'? (e) => {setScrollEnable(false);lastTouchRef.current = {x: e.nativeEvent.pageX,y: e.nativeEvent.pageY};}: void 0}onTouchMove={Platform.OS === 'android'? (e) => {// 默認在 touch 期間,禁用父滾動容器滾動能力,當達到指定閾值時,允許父滾動容器滾動setScrollEnable(scale === 1 &&Math.abs(e.nativeEvent.pageX - lastTouchRef.current.x) > 10 &&Math.abs(e.nativeEvent.pageY - lastTouchRef.current.y) < 12);lastTouchRef.current = {x: e.nativeEvent.pageX,y: e.nativeEvent.pageY};}: void 0}onTouchCancel={Platform.OS === 'android'? () => {setScrollEnable(true);}: void 0}onTouchEnd={Platform.OS === 'android' ? () => setScrollEnable(true) : void 0}><ScalePdfshowErrorTextresize={{ maxWidth: props.maxWidth, maxHeight: props.maxHeight }}source={{uri: props.item.uri,cache: true /** 當設置為 true 時,會在底層復用實例,頁面快速刷新時可能導致資源已經釋放又再次訪問資源從而拋出異常 */}}onScaleChanged={(scale) => {setScale(scale);}}/></View>);
}function ViewerDefault(props: ViewerItem) {const { primary, warning } = useThemeColor(['primary', 'warning']);return (<Viewstyle={{width: props.maxWidth,height: props.maxHeight,justifyContent: 'center',alignItems: 'center'}}><AntDesign name="unknowfile1" size={54} color={primary} /><ThemedText style={{ marginTop: 24, color: warning, fontSize: 18 }}>{i18n.t('viewer.previewNotSupported')}</ThemedText></View>);
}function requestItem(item: UriItem,maxWidth: number,maxHeight: number,setScrollEnable: (enable: boolean) => void
) {let Constructor: (props: ViewerItem) => React.JSX.Element;switch (item.type) {case 'png':case 'jpg':case 'jpeg':case 'webp':case 'bmp':case 'gif':case 'image': // 兜底類型Constructor = ViewerImage;break;case 'pdf':Constructor = ViewerPdf;break;default:Constructor = ViewerDefault;}return (<Constructoritem={item}maxWidth={maxWidth}maxHeight={maxHeight}setScrollEnable={setScrollEnable}/>);
}const Viewer = memo(function Viewer<T extends UriItem>(props: ViewerProps<T>) {const { isVisible, data, selectedIndex = 0, onClose } = props;const { top, bottom } = useSafeAreaInsets();const { width, height } = useWindowDimensions();const [currentIndex, setCurrentIndex] = useState(selectedIndex);const [showList, setShowList] = useState(false);const [scrollEnable, setScrollEnable] = useState(true);const itemWrapWidth = useMemo(() => width - 24, [width]);const itemWrapHeight = useMemo(() => height - top - bottom - 140,[bottom, height, top]);const setCanScroll = useCallback((enable: boolean) => {setScrollEnable(enable);}, []);const renderItem: ListRenderItem<T> = useCallback(({ item, index }) => {return (<Viewkey={index}style={[styles.item,{ width: itemWrapWidth, height: itemWrapHeight }]}>{requestItem(item, itemWrapWidth, itemWrapHeight, setCanScroll)}</View>);},[itemWrapHeight, itemWrapWidth, setCanScroll]);const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {const newIndex = Math.round(event.nativeEvent.contentOffset.x / width);setCurrentIndex(newIndex);},[width]);useEffect(() => {if (isVisible) {setCurrentIndex(selectedIndex);requestAnimationFrame(() => setShowList(true));} else {setShowList(false);}}, [isVisible, selectedIndex]);return (<FixBottomSheet isVisible={isVisible} fullWrapper onBackdropPress={onClose}><GestureHandlerRootView><View style={styles.previewContainer}><WrapPressEffectstyle={[styles.close, { marginTop: top }]}onPress={onClose}><AntDesign name="close" size={28} color="#FFF" /></WrapPressEffect><ThemedText style={styles.index} lightColor="#FFF" darkColor="#FFF">{`${currentIndex + 1} / ${data.length}`}</ThemedText><View style={styles.full}>{showList ? ( // 解決無法滾動問題<FlashListdata={data}horizontalpagingEnablednestedScrollEnabled// 禁用 ios 橡皮筋效果,避免拖動、縮放沖突bounces={false}scrollEnabled={data.length <= 1 ? false : scrollEnable} // 解決嵌套滾動沖突keyExtractor={(_, index) => index.toString()}initialScrollIndex={selectedIndex}onMomentumScrollEnd={onMomentumScrollEnd}showsHorizontalScrollIndicator={false}renderItem={renderItem}/>) : null}</View></View></GestureHandlerRootView></FixBottomSheet>);
});export const ViewerProvider = memo(function ViewerProvider(props: ViewerProviderProps
) {const [showViewer, setShowViewer] = useState(false);const [options, setOptions] = useState<ViewerOptions<UriItem>>({data: [],selectedIndex: 0});const onCloseRef = useRef<() => any>(null);const visibleRef = useRef(showViewer);if (showViewer && !visibleRef.current) {visibleRef.current = true;}const contextValue = useMemo<ViewerContextType>(() => ({showViewer(options) {setOptions(options);setShowViewer(true);onCloseRef.current = options.onClose ?? null;}}),[]);const handleClose = useCallback(() => {setShowViewer(false);setOptions({ data: [], selectedIndex: 0 });onCloseRef.current?.();onCloseRef.current = null;}, []);return (<ViewerContext.Provider value={contextValue}><FreezeComp>{props.children}</FreezeComp>{visibleRef.current ? (<Viewerkey={'viewer-display'}isVisible={showViewer}data={options.data}selectedIndex={options.selectedIndex}onClose={handleClose}/>) : null}</ViewerContext.Provider>);
});export function useViewer() {return useContext(ViewerContext);
}
整體思路是一個橫向全屏的滾動視圖,每個預覽視圖占據一屏,左右滑動切換;這里為了更好的可擴展性,每屏預覽視圖可能是不同的組件,這取決于文件類型,后續支持新的文件預覽類型時只需要添加對應的組件即可。
權限處理
應用如果要與用戶數據直接交互,就不可避免的需要處理權限,這里對所有權限的處理思路是一致。
先看代碼:
import { useAlertBox } from '@/components/provider/AlertProvider';
import i18n from '@/locales';
import { logInfo } from '@/utils/logger';
import { useCallback, useEffect, useRef } from 'react';
import { Linking } from 'react-native';
import { useCameraPermission as useVisionCameraPermission } from 'react-native-vision-camera';
import { useAppState } from './useAppState';export interface CameraPermissionOptions {onAlert?(): void;
}export function useCameraPermission(cb: (...args: any[]) => any,options: CameraPermissionOptions = {}
): (...args: any[]) => Promise<void> {const { onAlert } = options;const { isFocus } = useAppState();const { hasPermission, requestPermission } = useVisionCameraPermission();const { alertBox } = useAlertBox();const backWithsettings = useRef(false);const callbackRef = useRef(cb);const onAlertRef = useRef(onAlert);callbackRef.current = cb;onAlertRef.current = onAlert;const alertPermissions = useCallback(() => {onAlertRef.current?.();return alertBox({type: 'warning',title: i18n.t('permission.requestPermissions'),message: i18n.t('camera.cameraPermissionsTips'),confirm() {backWithsettings.current = true;Linking.openSettings();}});}, [alertBox]);const handleTrigger = useCallback(async (...args: any[]) => {try {if (!hasPermission && !(await requestPermission())) {return alertPermissions();}} catch (err: unknown) {return logInfo('debug-useCameraPermission', err);}callbackRef.current(...args);},[hasPermission, alertPermissions, requestPermission]);useEffect(() => {if (isFocus) {if (backWithsettings.current && !hasPermission) {requestPermission();}backWithsettings.current = false;}}, [isFocus, hasPermission, requestPermission]);return handleTrigger;
}
代碼邏輯如下:
-
調用此 hook,傳入一個回調函數
-
返回一個 trigger 觸發函數
-
當 trigger 被調用時,判斷是否有權限,如果有,直接調用回調
-
沒有權限,發起權限請求
- 用戶同意,調用回調
- 用戶拒絕,彈出彈窗,描述權限的作用,并提示用戶前往設置啟用權限,當用戶點擊確認時跳轉設置頁(用戶返回應用時,再次發起權限請求,不管同意或拒絕,不進行后續操作)
這里彈窗是必要的,當用戶多次拒絕時,系統不會再詢問用戶是否同意權限,而是默認拒絕。
文件處理
iOS 和 Android 平臺讀寫文件的差異較大,建議分開處理。
-
iOS 如果希望讀寫文件,可被用戶查看,設置
UIFileSharingEnabled=true
即可,這共享應用沙盒內的 document 目錄,可在文件 APP 中看到;由于此目錄位于應用沙盒內,所以不需要處理權限。 -
Android 這幾年對于存儲權限的變動較大,在不斷的收緊應用讀寫公共目錄的權力;在 SDK 33 及以上使用以往默認的權限請求方法無效,因為以前的權限策略被刪除,取代的是圖片、視頻、音頻訪問權限,以及所有文件訪問權限。
如果需要訪問除媒體文件外的目錄,可以使用 Android SAF 接口,或請求所有文件訪問權限。
如果應用需要上架 Goole Play, 所有文件訪問權限可能不容易過審
最好對不同的 Android 版本進行處理。
鍵盤處理
推薦使用 react-native-keyboard-controller
,提供了一系列強大的組件、hooks 以及一些命令式的 API。
這里主要說明如何解決 Android 系統下,快速聚焦失焦的時候導致的軟鍵盤抖動問題,關于這個問題我之前使用 uniapp 開發應用時也有遇到,感興趣的可以翻我之前的文章。
這個問題的原因很簡單:當軟件盤彈出時,頁面整體向上移動,這是為了不遮擋視圖;如果在軟鍵盤未完全彈出的時候,快速聚焦到另一個輸入框,導致軟鍵盤類型切換時(比如普通鍵盤切換到安全鍵盤),由于不知名原因,軟鍵盤開始快速在顯隱狀態下切換,導致頁面瘋狂抖動。
這里我的原因不同,是由于兩個輸入框都會彈出 model 層,軟鍵盤在這兩次聚焦時切換顯隱狀態導致的抖動問題,model 層是由狀態控制的,所以我的解決方案如下:
import { logInfo } from '@/utils/logger';
import { useCallback, useEffect, useRef, useState } from 'react';
import { KeyboardController, useKeyboardHandler } from 'react-native-keyboard-controller';
import { useState } from 'react';
import { runOnJS } from 'react-native-reanimated';export type UseKeyboardFixStateResult<T> = [T, (value: T) => void];export function useKeyboardStablize() {const [isStablize, setStablize] = useState(true);const setStablizeJS = (progress: number) => {setStablize(progress === 0 || progress === 1);};useKeyboardHandler({onStart() {'worklet';runOnJS(setStablizeJS)(-1);},onMove: (e) => {'worklet';runOnJS(setStablizeJS)(e.progress);},onEnd: (e) => {'worklet';runOnJS(setStablizeJS)(e.progress);}});return isStablize;
}export interface UseKeyboardFixStateOptions {hideKeyboard?: boolean;
}/*** 修復焦點爭奪,頁面抖動問題** @param cb* @returns*/
export function useKeyboardFixState<T>(initial: T,options: UseKeyboardFixStateOptions = {}
): UseKeyboardFixStateResult<T> {const { hideKeyboard = false } = options;const [isTriggered, setTriggered] = useState(false);const [value, setValue] = useState(initial);const cacheValueRef = useRef(value);const isStablize = useKeyboardStablize();const setValueCallback = useCallback((value: T) => {cacheValueRef.current = value;setTriggered(true);}, []);useEffect(() => {(async () => {if (isStablize) {if (isTriggered) {try {if (hideKeyboard) {await KeyboardController.dismiss();}setValue(cacheValueRef.current);} catch (err: unknown) {logInfo('debug-useKeyboardFixState', err);}setTriggered(false);}}})();}, [hideKeyboard, isStablize, isTriggered]);return [value, setValueCallback];
}
思路就是二次封裝 useState, 當 setValueCallback 觸發時,如果當前軟鍵盤還在過渡狀態,等待軟鍵盤高度穩定時(完全收起或完全彈出),隱藏軟鍵盤,當軟鍵盤隱藏時才真正觸發狀態變更。
這個思路時通用的,可以根據這個思路擴展到其他場景。
其他
-
i18n: expo-localization + i18n-js
-
持久化存儲:
- 非加密存儲:@react-native-async-storage/async-storage
- 加密存儲:expo-secure-store(有大小限制)
-
圖標:Expo Vector Icons
-
掃碼:react-native-vision-camera + @mgcrea/vision-camera-barcode-scanner
性能 & 應用大小優化
雖然目前主流手機性能都很好了,但還是有可能遇到卡頓的情況,比如使用舊版 Android 手機、低性能的功能機(PDA)時;同時如果你需要為 Android 打包 apk 時,體積可能會出乎意料的大,我們需要做些額外處理來解決這些情況。
減小 apk 大小
有三個關鍵設置可以減小打包后的 Android apk 大小:
-
減少支持的架構
默認的打包設置將
reactNativeArchitectures
設置為armeabi-v7a,arm64-v8a,x86,x86_64
,實際場景下其實只需要arm64-v8a
就能支持大部分 Android 手機了。 -
開啟混淆與資源壓縮
android.enableProguardInReleaseBuilds=true android.enableShrinkResourcesInReleaseBuilds=true
會對原生代碼進行混淆,并對資源壓縮。
注意: 開啟混淆時,打包后的應用程序在運行時可能找不到指定的類,需要在
proguard-rules.pro
文件排除特定類的混淆 -
開啟原生 library 的壓縮,可以大幅減少應用體積
expo.useLegacyPackaging=true
注意測試前后性能,有時為了極致壓縮應用體積而損耗性能是不值得的。
性能優化
主要是針對 Android 低性能設備上的優化,大部分措施在高性能設備上無感。
-
使用 React Compiler 與 React 19;目前還未感知到明顯的性能提示,但自動緩存策略可以減少心智負擔。
-
延遲渲染不可見的視圖,比如 Model 內的內容
-
嚴格測試基礎組件,包括渲染時長、重渲染次數;基礎組件被大量使用,積少成多也會導致性能不好的問題。
-
當頁面包含大量組件時(比如大型表單),可以使用 requestIdleCallback 延遲加載不重要、不可見的部分,相當于一次大又重的渲染任務拆分為多次小而輕的渲染任務。
-
部分原生、三方組件是可點按元素,不需要再包裹 Pressable 組件。
-
最重要且最顯著的,預加載基礎組件、大型頁面。
由于 expo 使用懶加載路由的方式,在首次進入一個包含大量組件的頁面時,會有明細的卡頓感,因為這個頁面使用的組件可能是首次加載,需要一定的加載時間,當后續再次進入時,卡頓感會消失。
為了解決這個問題,可以在應用啟動時預載基礎組件,在特定時間預載頁面。
iOS 真機調試 & 打包 & 過審 & 更新
相關文章網上有很多,這里說個大概的:
前提條件:
- 必須有開發者賬號(個人開發者賬號也行,只要你的應用體量不大)
- macOS(不知道算不算必須,可能有其他方式)
真機調試:
- xcode 登錄開發者賬號
- 登錄蘋果開發者平臺,前往 certificate,添加需要真機調試的設備
- 用 xcode 打開 ios 目錄,在簽名標簽欄中,選中自動簽名,選擇對應的開發 Team
- 首次調試 iOS 設備需要進行設置,自行查找
- 運行
npm run ios --device
選擇真機設備即可
打包:
-
使用 macOS 自帶的鑰匙串程序,創建證書
-
蘋果開發者平臺創建程序證書并下載(Ad hoc 是測試證書,此證書打包的應用只能安裝到指定設備,通常用于創建 release 的測試包)
-
下載下來的證書需要雙擊安裝到鑰匙串才能被 xcode 訪問
-
xcode 取消自動簽名,選擇指定證書,Product -> Archive 開始打包應用
-
打包完成后會在 Window -> Organizer 中生成一個打包記錄
-
在 App Store Connect 中新建一個應用,填寫相關資料(可以存儲草稿)
-
在 xcode 打包結果中,Distribute App 發布 App 到 App Store Connect 中
-
等待一段時間,如果應用測試沒有問題,在 App Store Connect 構建版本中可以選擇;
如果應用測試有問題,會發送郵件到開發者賬號,根據要求改然后回到步驟 4
-
App Store Connect 中如果一切準備就緒,可以發起審核
審核一般需要一兩天,如果審核不通過,可以在 App Store Connect 看到原因并回復,這里我們第一次審核失敗,原因是 2.1 應用完整性。
應用情況:公司內部使用,有私有賬戶、權限控制,需要登錄,無法在應用內注冊獲取賬戶。
參考網上的回復案例,明確告訴審核團隊,企業付費模式,獲得超級管理員賬戶后創建員工賬號,并附上管理員創建賬戶的附件視頻,參考:
Dear review team: After receiving the reason for the rejection you returned, we carefully verified the inside of the app and found that there is no problem you said. In order to avoid unnecessary misunderstandings, we have specially provided relevant qualification certificates and provided the following explanations:
Is your app restricted to users who are part of a single company? This may include users of the company’s partners, employees, and contractors.
No, our app is provided to employees of Chinese.
Is your app designed for use by a limited or specific group of companies?
If yes, which companies use this app?
If not, can any company become a client and utilize this app?
No, all functions are open. All Chinese corporate users can use this app.
What features in the app, if any, are intended for use by the general public?
All functions of the app are freely open to corporate employees.
How do users obtain an account?
You need to purchase services on a company basis.
We give the company a super administrator account. The company uses this account to log in to the background to create and manage company employee accounts. (See attachments).
Is there any paid content in the app and if so who pays for it? For example, do users pay for opening an account or using certain features in the app?
The app does not have a paid function. Users need to purchase services on a corporate basis. We give the company a super administrator account, and the company uses this account to log in to the background to create and manage company employee accounts.
回復后應用審核通過。
升級:
- 更新應用版本號
- 在 App Store Connect 原 APP 基礎上,新建一個版本
- 打包發布
- 應用測試通過后選擇構建版本發布審核