React Native for OpenHarmony 实战:反应测试实现

今天我们用 React Native 实现一个反应速度测试工具,测量用户从看到绿色到点击的反应时间。

状态设计

tsx 复制代码
import React, { useState, useRef, useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';

export const ReactionTest: React.FC = () => {
  const [state, setState] = useState<'idle' | 'waiting' | 'ready' | 'result' | 'early'>('idle');
  const [reactionTime, setReactionTime] = useState(0);
  const [results, setResults] = useState<number[]>([]);
  const startTimeRef = useRef(0);
  const timeoutRef = useRef<any>(null);
  
  const scaleAnim = useRef(new Animated.Value(1)).current;
  const pulseAnim = useRef(new Animated.Value(1)).current;
  const resultAnim = useRef(new Animated.Value(0)).current;

状态设计包含测试状态、反应时间、历史记录。

测试状态state 有 5 种状态:

  • 'idle':空闲,未开始
  • 'waiting':等待中,红色背景,等待变绿
  • 'ready':准备好,绿色背景,可以点击
  • 'result':显示结果,蓝色背景
  • 'early':点击太早,橙色背景,红色还没变绿就点了

反应时间reactionTime 是当前测试的反应时间,单位毫秒。

历史记录results 数组存储最近 5 次的反应时间。

两个 ref

  • startTimeRef:记录绿色出现的时间戳
  • timeoutRef:记录延迟定时器的 ID,用于清除定时器

三个动画值

  • scaleAnim:整体内容的缩放动画,点击时缩小再弹回
  • pulseAnim:绿色状态的脉冲动画,循环缩放
  • resultAnim:结果显示的缩放动画

为什么用 ref 存储时间戳和定时器 ID ?因为这些值不需要触发重新渲染。如果用 useState,每次更新都会重新渲染,浪费性能。ref 存储的值变化不会触发渲染,适合存储这类"幕后"数据。

脉冲动画

tsx 复制代码
  useEffect(() => {
    if (state === 'ready') {
      Animated.loop(
        Animated.sequence([
          Animated.timing(pulseAnim, { toValue: 1.05, duration: 200, useNativeDriver: true }),
          Animated.timing(pulseAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
        ])
      ).start();
    } else {
      pulseAnim.setValue(1);
    }
  }, [state]);

绿色状态时启动脉冲动画,其他状态停止。

条件判断 :如果状态是 'ready'(绿色),启动循环动画;否则重置动画值为 1。

循环动画:序列动画,从 1 到 1.05(200ms),再从 1.05 到 1(200ms),总共 400ms 一个周期。图标循环缩放,吸引用户注意。

为什么只在绿色状态脉冲?因为绿色是"可以点击"的信号,脉冲动画强调这个信号。其他状态不需要脉冲,避免干扰。

为什么缩放到 1.05?因为 5% 的缩放很微妙,像"呼吸"。如果缩放太大(比如 1.2),会太夸张,分散注意力。

开始测试

tsx 复制代码
  const start = () => {
    setState('waiting');
    scaleAnim.setValue(1);
    const delay = Math.random() * 3000 + 2000;
    timeoutRef.current = setTimeout(() => {
      setState('ready');
      startTimeRef.current = Date.now();
    }, delay);
  };

开始函数设置等待状态,随机延迟后变绿。

设置等待状态setState('waiting') 切换到等待状态,背景变红。

重置动画scaleAnim.setValue(1) 重置缩放动画为 1,确保动画从初始状态开始。

随机延迟Math.random() * 3000 + 2000

  • Math.random() * 3000:生成 0-3000 的随机数
  • + 2000:加上 2000,得到 2000-5000 的随机数
  • 延迟 2-5 秒后变绿

为什么用随机延迟?因为固定延迟让用户能预测变绿的时间,提前准备,测不出真实反应速度。随机延迟让用户无法预测,必须真正"看到绿色"才能反应。

为什么是 2-5 秒?因为 2 秒是最短等待时间,不会太快让用户措手不及;5 秒是最长等待时间,不会太久让用户不耐烦。

定时器回调

  • setState('ready'):切换到准备状态,背景变绿
  • startTimeRef.current = Date.now():记录变绿的时间戳

处理点击

tsx 复制代码
  const handlePress = () => {
    Animated.sequence([
      Animated.timing(scaleAnim, { toValue: 0.95, duration: 50, useNativeDriver: true }),
      Animated.spring(scaleAnim, { toValue: 1, friction: 3, useNativeDriver: true }),
    ]).start();

    if (state === 'idle' || state === 'result' || state === 'early') {
      start();
    } else if (state === 'waiting') {
      clearTimeout(timeoutRef.current);
      setState('early');
    } else if (state === 'ready') {
      const time = Date.now() - startTimeRef.current;
      setReactionTime(time);
      setResults([time, ...results.slice(0, 4)]);
      setState('result');
      
      resultAnim.setValue(0);
      Animated.spring(resultAnim, { toValue: 1, friction: 4, useNativeDriver: true }).start();
    }
  };

点击处理函数根据当前状态执行不同操作。

点击动画:序列动画,内容缩小到 95%(50ms),再弹回到 100%。给用户即时反馈。

状态判断

空闲、结果、太早状态 :调用 start() 开始新测试。这三种状态都是"可以开始"的状态。

等待状态

  • clearTimeout(timeoutRef.current):清除定时器,停止变绿
  • setState('early'):切换到太早状态,背景变橙色
  • 用户在红色时点击,说明点击太早

准备状态

  • Date.now() - startTimeRef.current:当前时间减去变绿时间,得到反应时间
  • setReactionTime(time):保存反应时间
  • setResults([time, ...results.slice(0, 4)]):把新结果插入数组开头,保留前 4 个旧结果,总共 5 个
  • setState('result'):切换到结果状态
  • 启动结果动画,从 0 缩放到 1

为什么保留 5 个历史记录?因为 5 个刚好,既能显示足够的统计信息(计算平均值),又不会占用太多内存。如果保留太多(比如 100 个),数组会很长;如果太少(比如 2 个),平均值不够准确。

统计计算

tsx 复制代码
  const getAverage = () => {
    if (results.length === 0) return 0;
    return Math.round(results.reduce((a, b) => a + b, 0) / results.length);
  };

计算平均反应时间。

检查空数组:如果没有历史记录,返回 0。

计算平均值

  • results.reduce((a, b) => a + b, 0):累加所有反应时间
  • / results.length:除以数量,得到平均值
  • Math.round(...):四舍五入取整

颜色和消息

tsx 复制代码
  const getColor = () => {
    switch (state) {
      case 'waiting': return '#e74c3c';
      case 'ready': return '#2ecc71';
      case 'early': return '#f39c12';
      default: return '#4A90D9';
    }
  };

  const getMessage = () => {
    switch (state) {
      case 'idle': return '点击开始';
      case 'waiting': return '等待绿色...';
      case 'ready': return '点击!';
      case 'early': return '太早了!';
      case 'result': return `${reactionTime} ms`;
    }
  };

  const getEmoji = () => {
    if (state !== 'result') return '';
    if (reactionTime < 200) return '🚀';
    if (reactionTime < 300) return '👍';
    if (reactionTime < 400) return '😊';
    return '🐢';
  };

三个函数根据状态返回背景色、消息、emoji。

背景色

  • 等待:红色(#e74c3c),表示"不能点"
  • 准备:绿色(#2ecc71),表示"可以点"
  • 太早:橙色(#f39c12),表示"警告"
  • 其他:蓝色(#4A90D9),默认色

为什么用红绿色?因为红绿是交通信号灯的颜色,全世界通用。红色表示"停",绿色表示"行"。用户看到颜色就知道能不能点击,不需要思考。

消息文字

  • 空闲:提示"点击开始"
  • 等待:提示"等待绿色..."
  • 准备:提示"点击!"
  • 太早:提示"太早了!"
  • 结果:显示反应时间"X ms"

结果 emoji:根据反应时间显示不同的 emoji:

  • 小于 200ms:🚀 火箭,超快
  • 小于 300ms:👍 点赞,不错
  • 小于 400ms:😊 笑脸,一般
  • 大于等于 400ms:🐢 乌龟,有点慢

为什么用这些阈值?因为人类的平均反应时间是 200-300ms。小于 200ms 是非常快的,小于 300ms 是正常的,小于 400ms 是稍慢的,大于 400ms 是明显慢的。

界面渲染

tsx 复制代码
  return (
    <TouchableOpacity
      style={[styles.container, { backgroundColor: getColor() }]}
      onPress={handlePress}
      activeOpacity={1}
    >
      <Animated.View style={[styles.content, { transform: [{ scale: scaleAnim }] }]}>
        <Animated.View style={{ transform: [{ scale: state === 'ready' ? pulseAnim : 1 }] }}>
          <Text style={styles.emoji}>
            {state === 'idle' && '👆'}
            {state === 'waiting' && '⏳'}
            {state === 'ready' && '🎯'}
            {state === 'early' && '⚠️'}
            {state === 'result' && getEmoji()}
          </Text>
        </Animated.View>
        
        <Animated.Text style={[styles.message, state === 'result' && {
          transform: [{ scale: resultAnim }],
        }]}>
          {getMessage()}
        </Animated.Text>
        
        {state === 'result' && (
          <Text style={styles.hint}>
            {reactionTime < 200 ? '超快!' : reactionTime < 300 ? '不错!' : reactionTime < 400 ? '一般' : '有点慢'}
          </Text>
        )}
        {state === 'early' && <Text style={styles.hint}>点击重试</Text>}
      </Animated.View>

整个屏幕是一个可点击区域,背景色根据状态变化。

容器

  • TouchableOpacity 让整个屏幕可点击
  • backgroundColor: getColor():背景色根据状态变化
  • activeOpacity={1}:点击时不改变透明度,因为已经有缩放动画

内容区域:应用缩放动画,点击时缩小再弹回。

图标

  • 应用脉冲动画(只在准备状态)
  • 根据状态显示不同的 emoji

消息文字

  • 显示当前状态的消息
  • 结果状态时应用缩放动画

提示文字

  • 结果状态:根据反应时间显示评价
  • 太早状态:提示"点击重试"

统计信息

tsx 复制代码
      {results.length > 0 && (
        <View style={styles.stats}>
          <View style={styles.statItem}>
            <Text style={styles.statLabel}>平均</Text>
            <Text style={styles.statValue}>{getAverage()} ms</Text>
          </View>
          <View style={styles.statItem}>
            <Text style={styles.statLabel}>最近</Text>
            <Text style={styles.statValue}>{results[0]} ms</Text>
          </View>
        </View>
      )}
    </TouchableOpacity>
  );
};

统计信息只在有历史记录时显示,固定在底部。

两个统计项

  • 平均反应时间:所有记录的平均值
  • 最近反应时间:最新一次的结果

为什么显示平均和最近?因为平均值反映整体水平,最近值反映当前状态。用户可以看到自己的平均水平,也可以看到刚才的表现。

鸿蒙 ArkTS 对比:测试逻辑

typescript 复制代码
@State state: string = 'idle'
@State reactionTime: number = 0
@State results: number[] = []
private startTime: number = 0
private timeoutId: number = -1

start() {
  this.state = 'waiting'
  const delay = Math.random() * 3000 + 2000
  this.timeoutId = setTimeout(() => {
    this.state = 'ready'
    this.startTime = Date.now()
  }, delay)
}

handlePress() {
  if (this.state === 'idle' || this.state === 'result' || this.state === 'early') {
    this.start()
  } else if (this.state === 'waiting') {
    clearTimeout(this.timeoutId)
    this.state = 'early'
  } else if (this.state === 'ready') {
    const time = Date.now() - this.startTime
    this.reactionTime = time
    this.results = [time, ...this.results.slice(0, 4)]
    this.state = 'result'
  }
}

getAverage(): number {
  if (this.results.length === 0) return 0
  return Math.round(this.results.reduce((a, b) => a + b, 0) / this.results.length)
}

ArkTS 中的测试逻辑完全一样,核心是状态机、随机延迟、时间戳计算。Date.now()setTimeoutMath.random() 都是标准 JavaScript API,跨平台通用。

样式定义

tsx 复制代码
const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  content: { alignItems: 'center' },
  emoji: { fontSize: 80, marginBottom: 20 },
  message: { fontSize: 56, color: '#fff', fontWeight: '700' },
  hint: { fontSize: 24, color: '#fff', marginTop: 20, opacity: 0.9 },
  stats: {
    position: 'absolute',
    bottom: 60,
    flexDirection: 'row',
    backgroundColor: 'rgba(0,0,0,0.3)',
    borderRadius: 20,
    padding: 20,
  },
  statItem: { alignItems: 'center', marginHorizontal: 20 },
  statLabel: { color: '#fff', fontSize: 14, opacity: 0.8 },
  statValue: { color: '#fff', fontSize: 24, fontWeight: '700', marginTop: 4 },
});

容器占满屏幕,居中对齐。图标字号 80,很大,醒目。消息字号 56,白色粗体。统计信息固定在底部,半透明黑色背景,白色文字。

小结

这个反应测试工具展示了状态机和时间戳的应用。用 5 种状态管理测试流程,用随机延迟防止预测,用时间戳精确测量反应时间。红绿背景色是直观的视觉信号,emoji 和评价让结果更有趣。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

相关推荐
风叶悠然2 小时前
vue3中数据的pinia的使用
前端·javascript·数据库
Jyywww1212 小时前
Uniapp+Vue3 使用父传子方法实现自定义tabBar
javascript·vue.js·uni-app
光影少年3 小时前
React vs Next.js
前端·javascript·react.js
谢尔登3 小时前
Vue3 响应式系统——ref 和 reactive
前端·javascript·vue.js
天若有情6733 小时前
【JavaScript】React 实现 Vue 的 watch 和 computed 详解
javascript·vue.js·react.js
OEC小胖胖3 小时前
16|总复习:把前 15 章串成一张 React 源码主线地图
前端·react.js·前端框架·react·开源库
满栀5853 小时前
插件轮播图制作
开发语言·前端·javascript·jquery
切糕师学AI3 小时前
Vue 中的计算属性(computed)
前端·javascript·vue.js
程琬清君3 小时前
Vue3DraggableResizable可移动范围有问题
前端·javascript·vue.js