React Native for OpenHarmony 实战:Steam 资讯 App 浏览历史页面

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam

用户在浏览游戏时,应用会自动记录浏览历史。这个功能不需要用户手动操作,只要点击进入游戏详情页,就会自动添加到历史记录中。浏览历史是一个很实用的功能,用户可以快速回到之前看过的游戏,而不需要重新搜索。

历史记录的数据结构设计

在设计历史记录功能时,需要考虑几个关键问题。首先是数据存储的策略。

存什么数据? 只存游戏的 AppId,不存完整的游戏信息。这样可以减少内存占用,而且游戏信息可能会变化(比如价格),每次显示时重新获取更准确。

存多少条? 限制为 50 条。太少了用户可能找不到之前看过的游戏,太多了会占用太多内存和存储空间。50 条是一个比较平衡的数字。

怎么排序? 最近浏览的放在最前面。这样用户打开历史页面,第一眼看到的就是刚才看过的游戏。

重复怎么办? 如果用户多次浏览同一个游戏,只保留最新的一条记录,并把它移到最前面。这样可以避免历史列表中出现重复的游戏。

基于这些考虑,在 AppContext 中实现了 addToHistory 函数。先看函数的定义:

tsx 复制代码
const addToHistory = (appId: number) => {
  setState(prev => ({
    ...prev,
    history: [appId, ...prev.history.filter(id => id !== appId)].slice(0, 50),
  }));
};

这一行代码看起来简洁,但实际上做了三件事:

  • 新记录放最前 - [appId, ...] 把新的 ID 放到数组最前面
  • 去重处理 - prev.history.filter(id => id !== appId) 从原数组中移除相同的 ID,避免重复
  • 限制数量 - .slice(0, 50) 只保留前 50 条,超出的自动丢弃

设计巧妙之处 - 这个函数用一行代码实现了新增、去重、限制三个功能。如果用户重复浏览同一个游戏,这个游戏会自动移到最前面,而不是添加一条新记录。

页面实现

浏览历史页面需要根据存储的 AppId 列表获取游戏详情,然后展示给用户。

状态定义

首先从全局状态中获取必要的数据和方法:

tsx 复制代码
export const HistoryScreen = () => {
  const {history, navigate, setSelectedAppId, addToHistory} = useApp();
  const [games, setGames] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

这里有两个关键的状态:

  • history - 从全局状态获取的 AppId 数组,存储了用户浏览过的游戏 ID
  • games - 本地状态,根据这些 ID 获取到的游戏详情

为什么要分开存? history 只是 ID 列表,我们需要调用 API 获取完整的游戏信息(名称、价格、封面等)才能展示。所以需要单独的 games 状态来存储完整的游戏数据。

加载游戏详情

页面加载时,需要根据历史记录中的 AppId 获取游戏详情。先看数据加载的逻辑:

tsx 复制代码
useEffect(() => {
  const loadHistoryGames = async () => {
    if (history.length === 0) {
      setGames([]);
      setLoading(false);
      return;
    }

首先检查历史记录是否为空,如果为空就直接返回,不需要发起 API 请求。

然后用 Promise.all() 并行获取所有游戏的详情:

tsx 复制代码
    setLoading(true);
    try {
      const gameDetails = await Promise.all(
        history.map(appId => getAppDetails(appId))
      );

这里用 Promise.all() 同时发起多个 API 请求,比逐个请求快得多。

接下来提取和处理返回的数据:

tsx 复制代码
      const gamesData = gameDetails
        .map((detail, index) => {
          const appId = history[index];
          const data = detail?.[appId]?.data;
          return data ? {id: appId, ...data} : null;
        })
        .filter(game => game !== null);
      
      setGames(gamesData);
  • 数据提取 - 从 API 响应中提取游戏数据
  • 添加 ID - 将 appId 添加到游戏对象中
  • 过滤空值 - 移除获取失败的游戏

最后处理错误和完成状态:

tsx 复制代码
    } catch (error) {
      console.error('Error loading history games:', error);
    } finally {
      setLoading(false);
    }
  };
  
  loadHistoryGames();
}, [history.length]);

依赖数组的选择 - 这里用的是 [history.length] 而不是 [history]。为什么?因为 history 是一个数组,React 比较数组时用的是引用比较。每次调用 addToHistory 都会创建一个新数组,即使内容完全一样,引用也不同,就会触发 useEffect 重新执行。用 history.length 就不会有这个问题,只有当历史记录的数量真正变化时,才会重新加载数据。

游戏卡片组件

历史页面的游戏卡片需要显示游戏信息和浏览顺序。先看卡片的基本结构:

tsx 复制代码
const HistoryGameCard = ({game, index}: {game: any, index: number}) => (
  <TouchableOpacity
    style={styles.gameCard}
    onPress={() => {
      setSelectedAppId(game.id);
      addToHistory(game.id);
      navigate('gameDetail');
    }}
  >

点击卡片时会调用 addToHistory(game.id),这会把这个游戏移到历史记录的最前面。所以用户从历史页面进入详情页再返回,这个游戏就会变成第 1 个。

然后是游戏的基本信息展示:

tsx 复制代码
    <Image
      source={{uri: game.header_image}}
      style={styles.gameImage}
      resizeMode="cover"
    />
    <View style={styles.gameInfo}>
      <Text style={styles.gameName} numberOfLines={2}>{game.name}</Text>
      <Text style={styles.gamePrice}>
        {game.price_overview?.final_price 
          ? `¥${(game.price_overview.final_price / 100).toFixed(2)}`
          : '免费'}
      </Text>
    </View>

卡片左侧显示游戏封面和基本信息,包括名称和价格。

最后是序号标记:

tsx 复制代码
    <View style={styles.indexBadge}>
      <Text style={styles.indexText}>{index + 1}</Text>
    </View>
  </TouchableOpacity>
);

序号的作用 - 卡片右侧有个序号标记,显示 1、2、3... 表示浏览顺序。1 是最近浏览的,数字越大表示浏览时间越早。这个小细节可以帮助用户快速定位最近看过的游戏。

清空历史功能

有时候用户想清空所有历史记录,比如不想让别人看到自己浏览过什么游戏。在 Header 右侧添加一个清空按钮。

首先实现清空历史的函数:

tsx 复制代码
const clearHistory = () => {
  Alert.alert(
    '清空浏览历史',
    '确定要清空所有浏览历史吗?',
    [
      {text: '取消', style: 'cancel'},
      {
        text: '确定',
        style: 'destructive',
        onPress: () => setGames([]),
      },
    ]
  );
};

这里用 Alert.alert 弹出确认对话框,避免用户误触。关键点:

  • 确认对话框 - 在执行危险操作前要求用户确认
  • style: 'destructive' - 会让"确定"按钮显示为红色,提醒用户这是一个危险操作
  • 取消选项 - 用户可以点击"取消"来中止操作

然后在 Header 中使用这个函数:

tsx 复制代码
<Header 
  title="浏览历史" 
  showBack 
  rightIcon="🗑️" 
  rightAction={games.length > 0 ? clearHistory : undefined}
/>

条件显示 - 只有当有历史记录时才显示清空按钮(games.length > 0)。如果历史为空,清空按钮就没有意义,所以不显示。

空状态处理

当历史记录为空时,需要显示一个友好的提示。先看空状态的结构:

tsx 复制代码
{games.length === 0 ? (
  <View style={styles.emptyContainer}>
    <Text style={styles.emptyIcon}>🕐</Text>
    <Text style={styles.emptyText}>还没有浏览记录</Text>
    <Text style={styles.emptySubtext}>浏览游戏后会自动记录在这里</Text>

空状态包含几个元素:

  • 时钟图标 - 用 🕐 表示历史记录
  • 主提示 - 告诉用户"还没有浏览记录"
  • 副提示 - 说明历史记录是自动的,不需要手动添加

然后提供一个操作按钮:

tsx 复制代码
    <TouchableOpacity
      style={styles.browseBtn}
      onPress={() => navigate('home')}
    >
      <Text style={styles.browseBtnText}>去浏览游戏</Text>
    </TouchableOpacity>
  </View>
) : (
  <FlatList ... />
)}

用户引导 - 空状态不只是告诉用户"没有数据",还要告诉用户怎么产生数据。这里用"浏览游戏后会自动记录在这里"说明历史记录是自动的,并提供"去浏览游戏"按钮让用户快速跳转到首页。

列表的渲染

当有历史记录时,用 FlatList 来渲染列表。先看基本的列表配置:

tsx 复制代码
<FlatList
  data={games}
  keyExtractor={(item) => item.id.toString()}
  renderItem={({item, index}) => <HistoryGameCard game={item} index={index} />}

这里的关键配置:

  • data - 传入游戏数组,FlatList 会遍历这个数组
  • keyExtractor - 用游戏 ID 作为 key,确保列表项的唯一性
  • renderItem - 用 HistoryGameCard 组件渲染每个游戏,并传入 index 用于显示序号

然后添加列表头部来显示历史记录数量:

tsx 复制代码
  ListHeaderComponent={
    <View style={styles.listHeader}>
      <Text style={styles.listHeaderText}>最近浏览 {games.length} 个游戏</Text>
    </View>
  }
/>

信息展示 - 列表头部显示历史记录数量,让用户一眼就知道自己浏览过多少个游戏。这个数字会随着用户浏览新游戏而增加(最多 50 个)。

页面的整体结构

浏览历史页面的整体布局分为三层:

tsx 复制代码
if (loading) {
  return (
    <View style={styles.container}>
      <Header title="浏览历史" showBack />
      <Loading />
      <TabBar />
    </View>
  );
}

return (
  <View style={styles.container}>
    <Header 
      title="浏览历史" 
      showBack 
      rightIcon="🗑️" 
      rightAction={games.length > 0 ? clearHistory : undefined}
    />
    {/* 空状态或列表 */}
    <TabBar />
  </View>
);

页面结构很清晰:

  • Header - 顶部导航栏,显示"浏览历史"标题,并开启返回按钮。当有历史记录时,右侧显示清空按钮
  • 内容区 - 根据 loading 状态显示 Loading 组件或游戏列表
  • TabBar - 底部导航栏,方便用户切换到其他页面

加载状态 - 当 loading 为 true 时,显示 Loading 组件。这样用户知道数据正在加载,而不是页面卡住了。

样式设计

浏览历史页面的样式采用 Steam 的深色主题。先看容器和空状态的样式:

tsx 复制代码
const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#171a21'},
  emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20},
  emptyIcon: {fontSize: 48, marginBottom: 16},
  emptyText: {fontSize: 18, color: '#acdbf5', marginBottom: 8},
  emptySubtext: {fontSize: 14, color: '#8f98a0', marginBottom: 24},

配色说明 - #171a21 是 Steam 最深的背景色,#acdbf5 是浅蓝色用于主要文字,#8f98a0 是灰色用于次要文字。

然后是按钮和列表头部的样式:

tsx 复制代码
  browseBtn: {paddingHorizontal: 24, paddingVertical: 12, backgroundColor: '#66c0f4', borderRadius: 8},
  browseBtnText: {fontSize: 14, color: '#171a21', fontWeight: '600'},
  listHeader: {padding: 12, backgroundColor: '#1b2838', borderBottomWidth: 1, borderBottomColor: '#2a475e'},
  listHeaderText: {fontSize: 14, color: '#8f98a0'},
  • 按钮 - 用 Steam 的标志蓝 #66c0f4,圆角 8,内边距 12
  • 列表头 - 背景色 #1b2838,底部加分割线 #2a475e

最后是游戏卡片和序号标记的样式:

tsx 复制代码
  gameCard: {flexDirection: 'row', alignItems: 'center', padding: 12, borderBottomWidth: 1, borderBottomColor: '#2a475e', backgroundColor: '#1b2838'},
  gameImage: {width: 80, height: 45, borderRadius: 4, marginRight: 12},
  gameInfo: {flex: 1},
  gameName: {fontSize: 14, fontWeight: '600', color: '#acdbf5', marginBottom: 4},
  gamePrice: {fontSize: 12, color: '#66c0f4'},
  indexBadge: {width: 24, height: 24, borderRadius: 12, backgroundColor: '#2a475e', justifyContent: 'center', alignItems: 'center', marginLeft: 8},
  indexText: {fontSize: 12, color: '#8f98a0', fontWeight: '600'},
});

卡片采用水平布局(flexDirection: 'row'),左侧是游戏封面和信息,右侧是序号标记。序号标记是一个圆形徽章,背景色 #2a475e,直径 24。

历史记录的持久化

目前历史记录存储在内存中,应用关闭后会丢失。如果要实现持久化,可以使用 AsyncStorage。首先导入 AsyncStorage:

tsx 复制代码
import AsyncStorage from '@react-native-async-storage/async-storage';

然后实现保存历史的函数:

tsx 复制代码
const saveHistory = async (history: number[]) => {
  try {
    await AsyncStorage.setItem('history', JSON.stringify(history));
  } catch (error) {
    console.error('Error saving history:', error);
  }
};

这个函数的作用:

  • 序列化 - 用 JSON.stringify() 将数组转换成字符串
  • 存储 - 用 AsyncStorage.setItem() 将字符串存储到本地
  • 错误处理 - 用 try-catch 捕获可能的错误

接下来实现加载历史的函数:

tsx 复制代码
const loadHistory = async () => {
  try {
    const data = await AsyncStorage.getItem('history');
    return data ? JSON.parse(data) : [];
  } catch (error) {
    console.error('Error loading history:', error);
    return [];
  }
};

这个函数的逻辑:

  • 读取数据 - 用 AsyncStorage.getItem() 从本地读取数据
  • 反序列化 - 用 JSON.parse() 将字符串转换回数组
  • 兜底处理 - 如果没有数据或出错,返回空数组

集成方式 - 在 AppContext 中,每当 history 变化时,调用 saveHistory() 保存数据。应用启动时,调用 loadHistory() 恢复数据。这样用户的浏览历史就能在应用关闭后保留。

实现浏览历史的完整流程

从用户的角度看,浏览历史功能的完整流程是这样的:

  1. 自动记录 - 用户点击游戏卡片进入详情页时,自动调用 addToHistory(appId)
  2. 状态更新 - 全局状态中的 history 数组被更新,新游戏被添加到最前面
  3. 自动刷新 - 历史页面的 useEffect 监听到 history.length 变化,自动重新加载数据
  4. 显示更新 - 新浏览的游戏出现在历史列表的最前面

关键设计 - 通过 useEffect 的依赖数组 [history.length],实现了历史状态和页面显示的自动同步。用户不需要手动刷新页面,一切都是自动的。

小结

浏览历史功能的核心是 addToHistory 函数的设计。一行代码实现了:新记录放最前、去重、限制数量三个功能。页面实现相对简单,主要是根据 ID 列表获取游戏详情并展示。

  • 数据结构 - 只存储游戏 ID,减少内存占用
  • 自动记录 - 用户浏览游戏时自动添加到历史
  • 智能排序 - 最近浏览的游戏放在最前面
  • 去重处理 - 重复浏览同一个游戏时自动去重
  • 清空功能 - 用户可以一键清空所有历史记录
  • 持久化 - 可以用 AsyncStorage 实现数据持久化

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

相关推荐
消失的旧时光-19432 小时前
从 WebView 到 React Native,再到 Flutter:用 Runtime 视角重新理解跨端框架
flutter·react native·react.js
半个开心果2 小时前
vue3项目结构里的hooks 和utils
前端·javascript·vue.js
HXH_csdn2 小时前
浏览器版本低,使用?.语法导致页面白屏
前端·javascript·vue.js
lili-felicity2 小时前
React Native for OpenHarmony 实战:图片懒加载(LazyLoading) 详解
javascript·react native·harmonyos
VT.馒头2 小时前
【力扣】2627. 函数防抖
前端·javascript·算法·leetcode
lili-felicity2 小时前
React Native for OpenHarmony 实战:滑动验证码 (Slider Captcha) 验证功能 详解
react native·react.js·harmonyos
摘星编程2 小时前
React Native for OpenHarmony 实战:LayoutAnimation 布局动画详解
javascript·react native·react.js
dear_bi_MyOnly2 小时前
用 Vibe Coding 打造 React 飞机大战游戏 —— 我的实践与学习心得
前端·react.js·游戏
用户90443816324602 小时前
拒绝 `setInterval`!手撕“死了么”生命倒计时,带你看看 60FPS 下的 Web Worker 优雅多线程
前端·javascript