普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月9日首页

React Native中创建自定义渐变色

作者 sure282
2026年3月9日 17:30

前言

所谓自定义渐变色就是允许用户按需选择颜色以及颜色的位置,一个基本的CSS渐变色配置是这样的: experimental_backgroundImage: 'linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)', 之前的文章提到过RN中实现渐变色的两种个方案,分别是RN的experimental_backgroundImage和使用expo-linear-gradient依赖库,以experimental_backgroundImage为例,如果我们想让用户实现自定义的渐变色方案应该允许用户配置 颜色颜色停留的位置渐变朝向角度。下面是界面样式:

微信图片_20260309095613_268_23.jpg微信图片_20260309095614_269_23.jpg

微信图片_20260309095615_270_23.jpg微信图片_20260309095615_271_23.jpg

微信图片_20260309095616_272_23.jpg微信图片_20260309095617_273_23.jpg

依赖库选择

因为我们需要一个调色板供用户选择颜色,因此我们需要一个依赖库来提供颜色选择功能:

reanimated-color-picker

它提供了多种颜色形式面板,比如圆形,条形,方形等选择界面UI组件,透明度控制和多种颜色格式获取,比如rgba格式和#ccc这种hex格式等,通常我们需要hex格式即可。该依赖库功能强大但是它没有详细文档,使用的最好方式是去它的项目中查看示例代码,非常详尽。由于我们想要创建渐变色效果应用于部分页面的渐变色背景,如果使用渐变色背景的页面是transparent_modal形式则不推荐使用透明度,将透明度始终设置为1,来避免显示底部页面内容。 以上我们创建了一个调色盘和颜色条,这二者便是以上依赖库为我们提供的样式UI:

import ColorPicker, { colorKit, HueSlider, Panel3, type ColorFormatsObject } from 'reanimated-color-picker';
    /**
     * ColorPick向外暴露onChange它是ui线程执行
     * onChangeJS和onCompleteJS都是在js线程执行的
     * ColorPick颜色选择行为会在点击调色盘和调整透明度时
     * 均会产生一个新颜色结果,而且渐变色的配置不适合调整透明度
     * 否则对于弹窗页面底部会显示出来
     * 它接收色彩对象,按需取制定格式即可
     */

                <ColorPicker
                    value={resultColor}
                    sliderThickness={16}
                    thumbSize={16}
                    thumbShape='circle'
                    onCompleteJS={onColorPick}
                    style={styles.picker}
                    boundedThumb
                >
                    <View
                        style={styles.panel}
                    >
                        <Panel3
                            style={styles.panelStyle}
                        />
                    </View>

                    <HueSlider
                        style={styles.sliderStyle}
                    />
                </ColorPicker>

它接收onChange事件函数以获取修改后的颜色,它内部使用react-native-reanimated依赖库因此动画控制以及值的变化有UI线程和JS线程之分,在这里我们使用js线程即可,因为我们要在值变化后修改组件接收的样式动态更新。

颜色位置

我们通常使用%百分比来控制颜色位置,这里我们使用一个slider,限定值的范围01或者0100,它的值在每次新增颜色后更新位置则与新的颜色配置绑定,因为每个颜色都应该有一个不同的位置,所以每一个渐变色存储对象中都应该有一个颜色和位置

渐变角度

渐变角度则是整个渐变色的配置,因此整个渐变色结果对象中只需要一个角度值。因此我们需要在创建渐变色时应该保存:渐变颜色n个,颜色位置n个,渐变角度1个:

单个颜色的基本配置字段,它同时也是底部已选择的颜色栏元素所需字段:

export interface ColorConfig {
    color: string,
    pos: number,
    id: string
};

当用户选择颜色超过1个时就可以创建渐变色,构造linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)

    const defaultColor = colorKit.randomRgbColor().hex();
    /** 记录当前选择的颜色项 */
    const [currentId, setCurrentId] = useState<string>('');
    /** 已选择的颜色数组 */
    const [colors, setColors] = useState<ColorConfig[]>([]);
    /** 删除模式还是新增模式 */
    const [mode, setMode] = useState<LinearMode>('select');
    /** 渐变色结果字符串,直接赋值给指定组件的样式 */
    const [experimental_backgroundImage, setExperimental_backgroundImage] = useState<string>('');

    const onColorPick = (color: ColorFormatsObject) => {
        setResultColor(color.hex);
        let obj = { color: color.hex, pos: sliderValue, id: generateId() };
        if (mode === 'select') {
            if (currentId) {
                setColors(prev => {
                    const newList = prev.map(el => {
                        if (el.id === currentId) {
                            return { ...el, color: color.hex };
                        };
                        return el;
                    });
                    if (newList.length > 1) {
                        const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                        const result = `linear-gradient(${angle}deg, ${stopsStr})`
                        setExperimental_backgroundImage(result);
                        if (viewItemsCount < newList.length) {
                            flatListRef.current?.scrollToOffset({ offset: newList.length * 55, animated: true });
                        };
                    };
                    return newList;
                });
            } else {
                setColors(prev => [...prev, obj]);
                setCurrentId(obj.id);
            };
        };
    };

以上有一个细节:在调色板组件中,底部颜色条,顶部圆盘以及颜色透明度的变化都视为颜色变化,触发onColorPick并生成一个新的颜色值结果,因此我们应该区分用户到底是在新增一个颜色还是在修改当前这个颜色,因此在颜色位置的Slider变化时应该有相同的逻辑处理:

    const handleSliderChange = (val: number) => {
        setSliderValue(Number(val.toFixed(1)));
        if (currentId) {
            setColors(prev => {
                const newList = prev.map(el => {
                    if (el.id === currentId) {
                        return { ...el, pos: val }
                    };
                    return el;
                });
                const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                const result = `linear-gradient(${angle}deg, ${stopsStr})`
                setExperimental_backgroundImage(result);
                return newList;
            });

        };
    };

颜色值和位置值的变化最终会影响渐变色,因此需要重新生成experimental_backgroundImage

角度值变化时也应该重新生成experimental_backgroundImage:

    /**
     * 角度值发生变化时执行
     * 添加节流处理,角度变化后
     * 会在已选颜色两种以上的情况下重新想修改
     * 渐变色
     */
    const handleAngelChange = (val: number) => {
        setAngle(Number(val.toFixed(0)));
        if (colors.length > 1) {
            const stopsStr = colors.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
            const result = `linear-gradient(${angle}deg, ${stopsStr})`
            setExperimental_backgroundImage(result);
        };
    };

完成以上三个控制后就是收获结果了将其转化为linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)格式保存,也没什么好说的,具体你想以哪种方式存储问题都不大,因为核心问题就是调色板,解决了它就是数据的修改保存,完整代码如下:

import { type FC, useState, useCallback, useRef } from 'react';
import PageTitleBar from '@/components/ui/PageTitleBar';
import StyledText from '@/components/ui/StyledText';
import { ThemedIonicons, ThemedView } from '@/components/theme/ThemedComponents';
import { View, StyleSheet, ScrollView, Platform, Pressable, TextInput, useWindowDimensions, KeyboardAvoidingView } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import type { ThemeType } from '@/types';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import CustomButton from '@/components/ui/CustomButton';
import ColorPicker, { colorKit, HueSlider, Panel3, type ColorFormatsObject } from 'reanimated-color-picker';
import Slider from '@react-native-community/slider';
import Switch from '@/components/ui/Switch';
import type { LinearMode } from '@/types/gradient';
import { useThemeConfig, useThemeNotification } from '@/hooks/useTheme';
import { generateId } from '@/utils';
import { insertGradient } from '@/libs/sqlite'
import { FlashList, type FlashListRef } from '@shopify/flash-list';
import ColorItem, { type ColorConfig } from '@/components/linearcolorselectpage/ColorItem';
import useProMusicStore from '@/store/proMusic';
const defaultColor = colorKit.randomRgbColor().hex();
const ColorPickerPage: FC = () => {
    const { type } = useLocalSearchParams<{ type: ThemeType }>();
    const { top, bottom } = useSafeAreaInsets();
    const [resultColor, setResultColor] = useState(defaultColor);
    const [colors, setColors] = useState<ColorConfig[]>([]);
    const flatListRef = useRef<FlashListRef<ColorConfig>>(null);
    const [sliderValue, setSliderValue] = useState<number>(0);
    const { text } = useThemeConfig();
    const { width } = useWindowDimensions()
    const showNotification = useThemeNotification();
    const [experimental_backgroundImage, setExperimental_backgroundImage] = useState<string>('');
    const [angle, setAngle] = useState<number>(0);
    const [mode, setMode] = useState<LinearMode>('select');
    const [title, setTitle] = useState<string>('')
    const [currentId, setCurrentId] = useState<string>('');
    const viewItemsCount = Math.floor((width - 40) / 55);
    /**
     * ColorPick向外暴露onChange它是ui线程执行
     * onChangeJS和onCompleteJS都是在js线程执行的
     * ColorPick颜色选择行为会在点击调色盘和调整透明度时
     * 均会产生一个新颜色结果,而且渐变色的配置不适合调整透明度
     * 否则对于弹窗页面底部会显示出来
     * 它接收色彩对象,按需取制定格式即可
     */
    const onColorPick = (color: ColorFormatsObject) => {
        setResultColor(color.hex);
        let obj = { color: color.hex, pos: sliderValue, id: generateId() };
        if (mode === 'select') {
            if (currentId) {
                setColors(prev => {
                    const newList = prev.map(el => {
                        if (el.id === currentId) {
                            return { ...el, color: color.hex };
                        };
                        return el;
                    });
                    if (newList.length > 1) {
                        const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                        const result = `linear-gradient(${angle}deg, ${stopsStr})`
                        setExperimental_backgroundImage(result);
                        if (viewItemsCount < newList.length) {
                            flatListRef.current?.scrollToOffset({ offset: newList.length * 55, animated: true });
                        };
                    };
                    return newList;
                });
            } else {
                setColors(prev => [...prev, obj]);
                setCurrentId(obj.id);
            };
        };
    };
    const handleBack = () => router.dismiss();
    const handleConfirm = async () => {
        if (colors.length < 2) {
            showNotification({ tip: '请至少选择两个颜色', type: 'warning' });
            return
        };
        if (!title) {
            showNotification({ tip: '请填写渐变色标题', type: 'warning' });
            return;
        };
        try {
            const configId = await insertGradient({
                id: generateId(),
                theme_type: type,
                title,
                is_active: 1,
                sort_order: 0,
                stops: colors.map(({ color, pos }, index) => ({
                    color,
                    position: pos,
                    id: generateId(),
                    sort_order: index,
                    config_id: '' // 会在 insertGradient 内部统一设置
                })),
                metadata: {
                    config_id: '', // 会在 insertGradient 内部统一设置
                    gradient_type: 'linear',
                    angle,
                },
            });
            if (configId) {
                showNotification({ tip: '保存成功', type: 'success' });
                const { setSignal } = useProMusicStore.getState();
                setSignal('RNLinearColorSelectPage');
                // 可以在这里执行保存后的操作,如返回上一页
                handleBack();
            } else {
                showNotification({ tip: '保存失败', type: 'error' })
            }
        } catch (error) {
            showNotification({ tip: '请稍后重试', type: 'warning' })
        };
    };
    const handleChangeMode = () => {
        setMode(prev => prev === 'select' ? 'delete' : 'select');
    };
    const handlePressColorItem = useCallback((item: ColorConfig, mode: LinearMode) => {
        if (mode === 'delete') {
            setColors(prev => prev.filter(({ id }) => id !== item.id));
        } else {
            const { id, color, pos } = item;
            setCurrentId(id);
            setResultColor(color);
            setSliderValue(pos);
        };
    }, []);
    const handleSliderChange = (val: number) => {
        setSliderValue(Number(val.toFixed(1)));
        if (currentId) {
            setColors(prev => {
                const newList = prev.map(el => {
                    if (el.id === currentId) {
                        return { ...el, pos: val }
                    };
                    return el;
                });
                const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                const result = `linear-gradient(${angle}deg, ${stopsStr})`
                setExperimental_backgroundImage(result);
                return newList;
            });

        };
    };
    /**
     * 角度值发生变化时执行
     * 添加节流处理,角度变化后
     * 会在已选颜色两种以上的情况下重新想修改
     * 渐变色
     */
    const handleAngelChange = (val: number) => {
        setAngle(Number(val.toFixed(0)));
        if (colors.length > 1) {
            const stopsStr = colors.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
            const result = `linear-gradient(${angle}deg, ${stopsStr})`
            setExperimental_backgroundImage(result);
        };
    };
    const handleTextChange = (val: string) => setTitle(val.trim());
    const themeType = type === 'light';
    const handleAddColor = useCallback(() => {
        const id = generateId();
        setCurrentId(id);
        setColors(prev => {
            const newItem = { ...prev[prev.length - 1], id };
            return ([...prev, newItem]);
        });
    }, []);
    return (<ThemedView
        style={[styles.container, { paddingBottom: bottom + 10, experimental_backgroundImage }]}
    >
        <PageTitleBar
            leftText='配置颜色'
            onPressLeft={handleBack}
        />
        <KeyboardAvoidingView
            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
            style={[styles.container, { paddingTop: top + 50 }]}
        >
            <ScrollView
                showsVerticalScrollIndicator={false}
            >
                <View
                    style={styles.colorPicker}
                >
                    <ThemedIonicons
                        name='color-palette-outline'
                        size={15}
                    />
                    <StyledText
                        size='SM'
                        weight='BOLD'
                    >颜色选择器</StyledText>
                </View>
                <ColorPicker
                    value={resultColor}
                    sliderThickness={16}
                    thumbSize={16}
                    thumbShape='circle'
                    onCompleteJS={onColorPick}
                    style={styles.picker}
                    boundedThumb
                >
                    <View
                        style={styles.panel}
                    >
                        <Panel3
                            style={styles.panelStyle}
                        />
                    </View>

                    <HueSlider
                        style={styles.sliderStyle}
                    />
                </ColorPicker>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name='pin-sharp'
                            size={14}
                        />
                        <StyledText
                            size='SM'
                            weight='BOLD'
                        >颜色位置</StyledText>
                    </View>
                    <Slider
                        style={styles.slider}
                        value={sliderValue}
                        step={.01}
                        minimumValue={0}
                        maximumValue={1}
                        onSlidingComplete={handleSliderChange}
                        maximumTrackTintColor='#ccc'
                    />
                    <StyledText
                        size='XXS'
                        textAlign='right'
                        weight='BOLD'
                        style={styles.sliderText}
                    >{`${Number(sliderValue.toFixed(2)) * 100}%`}</StyledText>
                </View>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name='compass-outline'
                            size={15}
                        />
                        <StyledText
                            size='SM'
                            weight='BOLD'
                        >渐变角度</StyledText>
                    </View>
                    <Slider
                        style={styles.slider}
                        value={angle}
                        step={1}
                        minimumValue={0}
                        maximumValue={360}
                        maximumTrackTintColor='#ccc'
                        onSlidingComplete={handleAngelChange}
                    />
                    <StyledText
                        size='XXS'
                        textAlign='right'
                        weight='BOLD'
                        style={styles.sliderText}
                    >{angle.toFixed(0)}deg</StyledText>
                </View>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name={themeType ? 'sunny-outline' : 'moon-outline'}
                            size={themeType ? 16 : 14}
                        />
                        <StyledText
                            weight='BOLD'
                            size='SM'
                        >{`已选颜色(${colors.length})`}</StyledText>
                    </View>
                    <View
                        style={styles.title}
                    >
                        <StyledText
                            size='SM'
                            onPress={handleChangeMode}
                            color={mode === 'select' ? text : '#e44444'}
                        >{mode === 'select' ? '选择模式' : '删除模式'}</StyledText>
                        <Switch
                            style={styles.switch}
                            active={mode === 'select'}
                            onChange={handleChangeMode}
                        />
                    </View>
                </View>
                <FlashList
                    ref={flatListRef}
                    style={styles.list}
                    data={colors}
                    keyExtractor={({ id }) => id}
                    horizontal
                    showsHorizontalScrollIndicator={false}
                    contentContainerStyle={styles.colorContent}
                    renderItem={({ item }) => <ColorItem
                        mode={mode}
                        item={item}
                        active={currentId === item.id}
                        onPress={handlePressColorItem}
                    />}
                    ListFooterComponent={<ColorItem
                        onPress={handleAddColor}
                        isEmpty
                    />}
                />
                <View
                    style={styles.colorPicker}
                >
                    <ThemedIonicons
                        name='create-outline'
                        size={16}
                    />
                    <StyledText
                        weight='BOLD'
                        size='SM'
                    >颜色名称:</StyledText>
                </View>
                <View
                    style={styles.inputArea}
                >
                    <TextInput
                        value={title}
                        onChangeText={handleTextChange}
                        placeholder='请输入颜色标题'
                        placeholderTextColor={text}
                        style={[styles.input, { color: text }]}
                        maxLength={15}
                    />
                    <Pressable
                        style={{ display: title ? 'flex' : 'none' }}
                        onPress={() => handleTextChange('')}
                    >
                        <ThemedIonicons
                            name='close-circle'
                            size={18}
                        />
                    </Pressable>
                </View>
            </ScrollView>
        </KeyboardAvoidingView>
        <View
            style={styles.buttonArea}
        >
            <CustomButton
                type='primary'
                text='取消'
                onPress={handleBack}
                style={styles.buttonStyle}
            />
            <CustomButton
                type='success'
                text='保存'
                onPress={handleConfirm}
                style={styles.buttonStyle}
            />
        </View>
    </ThemedView>);
};
const shadow = Platform.select({
    web: { boxShadow: 'rgba(0, 0, 0, 0.3) 0px 0px 2px' },
    default: {
        shadowColor: '#000',
        shadowOffset: {
            width: 0,
            height: 1,
        },
        shadowOpacity: 0.2,
        shadowRadius: 1.41,
        elevation: 2,
    },
});
const styles = StyleSheet.create({
    container: {
        flex: 1
    },
    input: {
        flex: 1,
        height: 30,
        paddingVertical: 2
    },
    slider: {
        flex: 1,
    },
    sliderText: {
        width: 40
    },
    list: {
        height: 65,
        width: '100%',
        paddingHorizontal: 20
    },
    titleBar: {
        width: '100%',
        paddingHorizontal: 20,
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        paddingVertical: 15
    },
    colorPicker: {
        paddingHorizontal: 20,
        flexDirection: 'row',
        gap: 4,
        alignItems: 'center',
        paddingVertical: 10
    },
    inputArea: {
        flex: 1,
        flexDirection: 'row',
        alignItems: 'center',
        gap: 2,
        borderBottomColor: '#ccc',
        borderBottomWidth: 1,
        marginHorizontal: 20,
    },
    title: {
        flexDirection: 'row',
        alignItems: 'center',
        gap: 4
    },
    previewContainer: {
        alignItems: 'center',
        paddingVertical: 10
    },
    colorContent: {
        gap: 5,
    },
    colorCard: {
        width: 40,
        height: 40,
        borderRadius: 10,
        borderWidth: 1
    },
    buttonArea: {
        flexDirection: 'row',
        justifyContent: 'space-evenly',
        alignContent: 'center',
        paddingTop: 20,
        width: '100%'
    },
    card: {
        borderRadius: 10,
        overflow: 'hidden'
    },
    tip: {
        position: 'absolute',
        right: 1,
        top: 1
    },
    switch: {
        width: 28,
        height: 14
    },
    buttonStyle: {
        width: 120
    },
    picker: {
        paddingHorizontal: 20,
        gap: 10,
        paddingBottom: 20
    },
    panel: {
        width: '100%',
        alignItems: 'center'
    },
    panelStyle: {
        width: 260,
        ...shadow,
    },
    sliderStyle: {
        borderRadius: 20,
        ...shadow,
    },
    sliderVerticalStyle: {
        borderRadius: 20,
        height: 300,
        ...shadow,
    },
    previewTxt: {
        color: '#707070',
        fontFamily: 'Quicksand',
    },
    content: {

        padding: 20,

    }

});
export default ColorPickerPage;

有任何疑问可以查看[项目代码](expo rn: expo创建的react native的音乐播放器应用,专注视频转歌和本地歌曲播放)

React Native 物理按键扫码监听终极方案:从冲突到完美共存

2026年3月9日 16:46

React Native 物理按键扫码监听终极方案:从冲突到完美共存

写给所有被 PDA 扫码折磨的开发者,以及未来的自己。

如果你正在开发 PDA(手持终端)应用,并且遇到了“全局监听和页面监听打架”、“扫码结果在这个页面能收到,在那个页面就收不到”的问题,那么这篇文章就是为你准备的。

1. 遇到的问题

在开发仓库管理系统(WMS)或类似 PDA 应用时,物理扫码键是最常用的交互方式。我们通常会有两种需求:

  1. 全局监听:不管在哪个页面,我都要记录扫码历史,或者做一些全局的日志记录。
  2. 页面监听:在具体的业务页面(比如入库单、盘点单),我需要拿到扫码结果去请求接口、查询商品。

最初的痛点: 当我们使用原生的 DeviceEventEmitter 或者简单的封装时,往往会遇到“单播”的尴尬——一旦我在具体的业务页面开始监听扫码,全局的那个监听器就被“顶”掉了,失效了;或者反过来,全局监听器把事件拦截了,业务页面收不到。

2. 解决方案的核心思想:多播(Multicast)

要解决这个问题,我们需要一个“中间人”(Manager)。

  • 以前的模式(单播):原生事件 -> Manager -> 唯一的监听者(谁最后注册谁就赢)。
  • 现在的模式(多播):原生事件 -> Manager -> 监听者列表(Set) -> 分发给所有注册的人。

这样,无论是全局的 Context,还是具体的页面组件,只要向 Manager 注册了,大家都能收到通知,互不干扰!

3. 代码实现全解析

3.1 底层管理者:PhysicalKeyScanManager

这是最核心的部分。它负责跟原生模块打交道,并维护一个监听者列表。

关键点:

  • 使用 Set 来存储回调函数,自动去重。
  • startListening 不再覆盖旧的回调,而是 add 进列表。
  • stopListening 只移除指定的回调,而不是清空所有。
// src/utils/PhysicalKeyScanManager.js

class PhysicalKeyScanManager {
  constructor() {
    // ...
    this.listeners = new Set(); // 核心:存放所有监听者的集合
    // ...
  }

  // 收到原生事件后的处理
  _handleScanResult = (result) => {
    // ... 包装数据 ...
  
    // 核心:遍历列表,人人有份
    this.listeners.forEach(callback => {
        if (callback) callback(scanData);
    });
  };

  startListening(callback) {
    // 1. 把新来的监听者加入集合
    if (callback) this.listeners.add(callback);

    // 2. 如果是第一个监听者,才真正去建立原生连接(省资源)
    if (!this.scanSubscription) {
      this.scanSubscription = this.scanEventEmitter.addListener(
        'onScanResult',
        this._handleScanResult
      );
    }
  
    // 3. 返回一个取消函数,方便 useEffect 清理
    return () => this.stopListening(callback);
  }

  stopListening(callback) {
    // 1. 只移除这一个监听者
    if (callback) this.listeners.delete(callback);

    // 2. 如果人走茶凉(列表空了),就把原生连接也断了
    if (this.listeners.size === 0 && this.scanSubscription) {
      this.scanSubscription.remove();
      this.scanSubscription = null;
    }
  }
}

3.2 全局大管家:ScanContext

我们在 App 的最顶层(App.js)包裹这个 Provider。它的作用是从 App 启动那一刻起,就占一个坑位

它负责:

  • 初始化扫码服务(autoInit)。
  • 记录所有的扫码历史(history)。
  • 提供全局状态。
// src/context/ScanContext.js

useEffect(() => {
  physicalKeyScanManager.autoInit();

  // 注册全局监听,因为 Manager 支持多播,这里注册了也不会影响别的页面
  const unsubscribe = physicalKeyScanManager.startListening(result => {
    console.log('[全局记录] 收到扫码:', result.code);
    setHistory(prev => [result, ...prev]);
  });

  return () => unsubscribe();
}, []);

3.3 页面级的 Hook:usePhysicalKeyScan

这是给普通业务页面用的。它的特点是智能管理生命周期

  • 页面获得焦点时:自动开始监听。
  • 页面失去焦点时:自动停止监听。

这样能保证用户不在当前页面时,不会意外触发当前页面的逻辑。

注意:为了防止 React Hooks 的闭包陷阱导致监听器重复注册(出现收一次码打印两次日志的 Bug),我们在实现时使用了局部变量锁定的技巧,确保清理函数总是清理当前周期创建的那个监听器。

// src/hooks/usePhysicalKeyScan.js

useFocusEffect(
  useCallback(() => {
    let unsubscribe = null; // 局部变量,锁定当前周期的监听器

    if (autoStart) {
      // 页面来了,注册监听,并赋值给局部变量
      unsubscribe = physicalKeyScanManager.startListening(handleScanResult);
      // 同步到 ref 供外部(如卸载时)使用
      unsubscribeRef.current = unsubscribe;
      // ...
    }

    return () => {
      // 页面走了,使用局部变量进行清理,精准打击
      if (unsubscribe) {
        unsubscribe();
      }
      // ...
    };
  }, [autoStart])
);

3.4 进阶 Hook:useContextualPhysicalKeyScan

这是最强大的 Hook,专门解决**“我要把这个码扫给谁?”**的问题。

比如在一个物料列表中,点击某一行,然后扫码,把条码填入该行。

  • setContext(item):设置当前正在操作的对象(上下文)。
  • onScan(result, context):回调里会把当时的上下文带回来给你。
// src/hooks/useContextualPhysicalKeyScan.js

const setContext = useCallback((context) => {
  contextRef.current = context; // 存起来
  // 可以设置个超时,比如30秒后自动清除,防止误操作
}, []);

const handleScanResult = useCallback((result) => {
  // 触发回调时,把上下文也传出去
  onScan(result, contextRef.current);
}, []);

4. 如何使用?(小白看这里)

场景一:我就想在页面里拿扫码结果

直接用 usePhysicalKeyScan

import usePhysicalKeyScan from '@/hooks/usePhysicalKeyScan';

const MyPage = () => {
  usePhysicalKeyScan({
    onScan: (result) => {
      alert(`扫到了:${result.code}`);
      // 这里调用接口查询...
    }
  });

  return <View>...</View>;
};

场景二:我有好几个输入框/列表项,我要区分扫给谁

useContextualPhysicalKeyScan

import useContextualPhysicalKeyScan from '@/hooks/useContextualPhysicalKeyScan';

const ListPage = () => {
  const { setContext } = useContextualPhysicalKeyScan({
    onScan: (result, context) => {
      if (context) {
        console.log(`把条码 ${result.code} 赋值给商品 ${context.name}`);
        // 更新列表数据...
      } else {
        console.log('没选中商品,扫码无效或作为通用查询');
      }
    }
  });

  return (
    <View>
      {items.map(item => (
        <TouchableOpacity 
          key={item.id} 
          onPress={() => setContext(item)} // 点击选中,告诉 Hook “接下来扫码是给它的”
        >
          <Text>{item.name}</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
};

5. 总结

通过改造 PhysicalKeyScanManager 为多播模式,我们完美实现了:

  1. 全局不掉线:ScanContext 里的历史记录永远在记录。
  2. 页面互不扰:A 页面监听扫码,不会影响 B 页面;离开 A 页面自动停止监听。
  3. 上下文可追踪:清楚地知道当前这一次扫码是为了哪个业务对象。

这就是 PDA 物理按键扫码的“终极解决方案”。🚀

昨天以前首页

React Native 性能优化指南

作者 野生好人
2026年3月6日 09:49

React Native 性能优化指南(2024-2026)

React Native 在 2024 年底至 2026 年间经历了重大的架构演进(New Architecture),结合 Hermes 引擎的默认化,使其性能接近原生应用。以下是基于最新生态和官方更新的系统性优化指南。

1. 核心架构与引擎(基础升级)

目的:解决原有桥接(Bridge)导致的性能瓶颈。 操作

  • 迁移 New Architecture:将项目迁移到最新架构(默认从 RN 0.76 开始),启用 Fabric 渲染引擎和 TurboModules。这是提升渲染性能的根本措施[[1]][[2]]。
  • 启用 Hermes 引擎:强制开启 Hermes AOT 编译(预编译),并开启 hermes bytecode 功能,显著减少启动时间和内存占用[[3]][[4]]。
  • 避免不必要的桥接:尽量使用 JSI(JavaScript Interface)或 TurboModules 替代传统的 NativeModules,降低 JavaScript 与原生的通信开销[[5]]。

2. 渲染层优化(防止卡顿)

目的:降低 UI 线程压力,避免掉帧。 操作

  • 列表渲染:对于长列表,优先使用 FlashList 替代 FlatList,因为它采用了更高效的布局算法;若使用 FlatList,务必设置 keyExtractorremoveClippedSubviewswindowSize 参数[[6]]。
  • 避免重复渲染:使用 React.memo 包裹函数组件,使用 useMemo 缓存计算结果,使用 useCallback 缓存函数引用,防止子组件不必要的重新渲染[[7]][[8]]。
  • 图片优化:使用 react-native-fast-image 替代 Image 组件,开启磁盘缓存、预取图片并使用占位图,避免加载高分辨率图片导致的卡顿[[9]]。
  • 动画流畅:避免使用 Animated 的 JS driver。改用 react-native-reanimated 3,将动画逻辑迁移至 UI 线程(C++层),确保动画的 60fps 流畅性[[10]]。

3. JavaScript 线程优化(代码层)

目的:减少 JS 主线程阻塞。 操作

  • 性能监控:使用 Flipper 的 React DevTools 插件或 react-native-performance 库进行帧率监控,定位具体卡顿点[[11]][[12]]。
  • 避免大对象创建:在渲染循环中避免创建大对象或执行大量计算。将复杂逻辑提取到 Web Worker 中(使用 react-native-workers)或通过 TurboModules 转为原生执行[[13]]。
  • 代码分割:对于大型页面,使用动态 import() 进行懒加载,减小单个 Bundle 大小,加快加载速度[[14]]。

4. 内存管理与资源释放

目的:防止内存泄漏导致的崩溃。 操作

  • 及时释放资源:在组件的 useEffect 返回函数或 componentWillUnmount 中,移除所有事件监听、计时器和网络请求,防止回调引用导致的内存泄漏[[15]]。
  • 图片资源:使用 FastImageclearDiskCacheclearMemoryCache 方法定期清理缓存,防止 OOM(内存溢出)[[16]]。
  • 键盘弹出:在使用 KeyboardAvoidingView 时,尽量设置 behavior="padding" 并控制键盘监听的数量,键盘弹出是常见的卡顿来源[[17]]。

5. 包体积与安全性优化

目的:减小 App 下载体积,提高安全性。 操作

  • ProGuard/R8:在 Android 项目中启用 ProGuard(enableProguardInReleaseBuilds),移除未使用的 Java 类和资源[[18]]。
  • 移除 Debug 代码:确保发布版(Release)中移除所有 console.logdebugger 语句,关闭开发者菜单[[19]]。
  • 依赖审计:定期使用 npm audit 检查依赖库的安全性和冗余程度,删除未使用的第三方库[[20]]。

6. 迁移与版本管理

目的:确保新架构兼容性。 操作

  • 版本锁定:使用 Yarn 的 resolutions 锁定 React Native 关键依赖版本,防止因子依赖升级导致的崩溃[[21]]。
  • 分支管理:为迁移 New Architecture 创建专门的 new-arch 分支,逐步替换旧模块,避免一次性迁移导致的全局错误[[22]]。

参考文献

  1. React Native 性能优化实战指南(2026最新版)[[23]]
  2. React Native 新架构概念解析[[24]]
  3. React Native 性能优化 Checklist[[25]]
  4. Callstack 社区 2024 性能更新与指南[[26]]
  5. 优化 React Native 性能:技巧与最佳实践(GCC Marketing)[[27]]
❌
❌