React Native for OpenHarmony 实战:Steam 资讯 App 游戏详情页面

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

游戏详情页是整个应用的信息中心,需要聚合多个 API 的数据。这篇文章来实现这个相对复杂的页面。
请添加图片描述

多 API 数据聚合

appdetails 接口 提供游戏的基本信息,包括名称、价格、介绍、截图、视频等。

GetNumberOfCurrentPlayers 接口 返回当前在线玩家数。

GetSchemaForGame 接口 提供游戏的所有成就列表。

GetGlobalAchievementPercentagesForApp 接口 返回各成就的达成率。

GetNewsForApp 接口 提供最新的游戏相关新闻。

实际开发中需要并行请求这些 API,然后聚合到一个页面上。

页面布局

核心代码实现

组件初始化和状态管理

export const GameDetailScreen = () => {
  const {selectedAppId, navigate, favorites, toggleFavorite} = useApp();
  const [game, setGame] = useState<any>(null);
  const [playerCount, setPlayerCount] = useState<number | null>(null);
  const [loading, setLoading] = useState(true);

这里做了什么: 从 AppContext 中获取当前选中的游戏 ID、导航函数、收藏列表等。然后定义了三个状态:game 存储游戏数据,playerCount 存储在线人数,loading 控制加载状态。

数据加载的并行处理

  useEffect(() => {
    if (!selectedAppId) return;
    Promise.all([
      getAppDetails(selectedAppId),
      getPlayerCount(selectedAppId),
    ]).then(([details, players]) => {
      setGame(details?.[selectedAppId]?.data);
      setPlayerCount(players?.response?.player_count);
      setLoading(false);
    }).catch(() => setLoading(false));
  }, [selectedAppId]);

这里做了什么: 使用 Promise.all() 并行请求两个 API。这样做的好处是:

  • 性能优化 - 如果串行请求,总时间是两个请求的和;并行请求,总时间是最慢的那个请求的时间
  • 用户体验 - 用户不需要等待第一个请求完成才开始第二个请求
  • 代码简洁 - 用 Promise.all() 比手动管理多个异步操作更清晰

如果需要调用更多 API(比如成就、新闻等),只需要加到数组中即可。

加载和错误状态的处理

  if (loading) return <View style={styles.container}><Header title="游戏详情" showBack /><Loading /></View>;
  if (!game) return <View style={styles.container}><Header title="游戏详情" showBack /><Text style={styles.error}>加载失败</Text></View>;

这里做了什么: 在渲染主要内容之前,先检查加载状态和数据是否存在。如果还在加载,显示 Loading 组件;如果加载失败,显示错误提示。这样可以避免渲染 undefined 的数据导致应用崩溃。

收藏状态的判断

  const isFavorite = selectedAppId ? favorites.includes(selectedAppId) : false;

这里做了什么: 检查当前游戏是否在收藏列表中。这个值会用来决定 Header 右侧显示的 emoji 是 ❤️(已收藏)还是 🤍(未收藏)。

页面主体结构

  return (
    <View style={styles.container}>
      <Header 
        title={game.name} 
        showBack 
        rightIcon={isFavorite ? '❤️' : '🤍'} 
        rightAction={() => selectedAppId && toggleFavorite(selectedAppId)} 
      />
      <ScrollView style={styles.content}>
        {/* 各个内容区域 */}
      </ScrollView>
    </View>
  );

这里做了什么: 使用 ScrollView 包裹所有内容,这样当内容超过屏幕高度时,用户可以向下滚动查看。Header 组件在顶部固定,右侧的收藏按钮点击时会调用 toggleFavorite() 来切换收藏状态。

价格信息的展示

<View style={styles.priceSection}>
  {game.price_overview ? (
    <>
      {game.price_overview.discount_percent > 0 && (
        <View style={styles.discountBadge}>
          <Text style={styles.discountText}>-{game.price_overview.discount_percent}%</Text>
        </View>
      )}
      <Text style={styles.price}>{game.price_overview.final_formatted}</Text>
    </>
  ) : (
    <Text style={styles.price}>免费游玩</Text>
  )}
  {playerCount && <Text style={styles.players}>🎮 {playerCount.toLocaleString()} 人在线</Text>}
</View>

这里做了什么:

  • 价格判断 - 如果 price_overview 存在,说明游戏是付费的;否则是免费游戏
  • 折扣显示 - 如果折扣大于 0,显示折扣徽章
  • 在线人数 - 使用 toLocaleString() 格式化数字,比如 1000000 会显示为 1,000,000,更容易阅读

这个设计能够处理三种情况:免费游戏、付费游戏、打折游戏。

快捷操作按钮

<View style={styles.actions}>
  <TouchableOpacity style={styles.actionBtn} onPress={() => navigate('gameNews')}>
    <Text style={styles.actionIcon}>📰</Text>
    <Text style={styles.actionLabel}>新闻</Text>
  </TouchableOpacity>
  {/* 其他按钮类似 */}
</View>

这里做了什么: 创建五个快捷按钮,每个按钮都有一个 emoji 图标和标签。点击时会导航到对应的页面。这些页面(新闻、成就、截图、视频、评测)会在后续的文章中逐一实现。

使用 emoji 而不是图片的好处是:

  • 文件体积小 - 不需要额外的图片资源
  • 加载快 - emoji 是文本,加载速度很快
  • 易于维护 - 改变 emoji 只需要改一个字符

游戏信息的展示

<View style={styles.section}>
  <Text style={styles.sectionTitle}>游戏信息</Text>
  <View style={styles.infoRow}>
    <Text style={styles.infoLabel}>开发商</Text>
    <Text style={styles.infoValue}>{game.developers?.join(', ') || '未知'}</Text>
  </View>
  <View style={styles.infoRow}>
    <Text style={styles.infoLabel}>发行商</Text>
    <Text style={styles.infoValue}>{game.publishers?.join(', ') || '未知'}</Text>
  </View>
  <View style={styles.infoRow}>
    <Text style={styles.infoLabel}>发行日期</Text>
    <Text style={styles.infoValue}>{game.release_date?.date || '未知'}</Text>
  </View>
  {game.metacritic && (
    <View style={styles.infoRow}>
      <Text style={styles.infoLabel}>Metacritic</Text>
      <Text style={[styles.infoValue, styles.metacritic]}>{game.metacritic.score}</Text>
    </View>
  )}
</View>

这里做了什么:

  • 数组处理 - developerspublishers 是数组,用 join(', ') 将它们连接成字符串
  • 默认值 - 如果某个字段不存在,显示"未知"而不是 undefined
  • 条件渲染 - Metacritic 评分不是所有游戏都有,所以用 game.metacritic && 来条件渲染
  • 样式覆盖 - Metacritic 分数用 [styles.infoValue, styles.metacritic] 应用多个样式,后面的样式会覆盖前面的

游戏类型标签

{game.genres && (
  <View style={styles.section}>
    <Text style={styles.sectionTitle}>游戏类型</Text>
    <View style={styles.tags}>
      {game.genres.map((g: any) => (
        <View key={g.id} style={styles.tag}>
          <Text style={styles.tagText}>{g.description}</Text>
        </View>
      ))}
    </View>
  </View>
)}

这里做了什么:

  • 条件渲染 - 如果游戏没有类型信息,整个区域都不显示
  • 列表渲染 - 用 map() 遍历所有类型,每个类型都渲染成一个标签
  • 唯一 key - 用 g.id 作为 key,这样 React 能正确追踪每个标签

标签用了圆角(borderRadius: 16),这是现代 UI 的常见做法。

平台支持的展示

<View style={styles.section}>
  <Text style={styles.sectionTitle}>支持平台</Text>
  <View style={styles.platforms}>
    {game.platforms?.windows && <Text style={styles.platform}>🪟 Windows</Text>}
    {game.platforms?.mac && <Text style={styles.platform}>🍎 macOS</Text>}
    {game.platforms?.linux && <Text style={styles.platform}>🐧 Linux</Text>}
  </View>
</View>

这里做了什么:

  • 条件渲染 - 只显示游戏支持的平台
  • Emoji 表示 - 用不同的 emoji 表示不同的平台,更直观
  • 可选链 - 使用 ?. 操作符安全地访问嵌套属性

样式设计的细节

颜色体系

我们使用了 Steam 官方的深色主题配色:

  • #171a21 是主背景色,最深的颜色,用于整个页面背景
  • #1b2838 是次背景色,用于卡片和分区,比主背景色稍浅
  • #2a475e 是边框和分割线的颜色,用来区分不同的区域
  • #acdbf5 是主文字色,浅蓝色,用于主要文本
  • #8f98a0 是辅助文字色,灰色,用于标签和说明文字
  • #66c0f4 是强调色,Steam 蓝,用于重要信息和交互元素

这套配色方案在整个应用中保持一致,给用户一个统一的视觉体验。

间距和排版

section: {padding: 16, borderTopWidth: 1, borderTopColor: '#2a475e'},
sectionTitle: {fontSize: 16, fontWeight: 'bold', color: '#fff', marginBottom: 12},
description: {fontSize: 14, color: '#acdbf5', lineHeight: 22},

这里的设计考量:

  • padding 16 - 这是一个常见的间距单位,既不会太拥挤,也不会太空旷
  • 边框分割 - section 之间用 1px 的边框分割,而不是用空白,这样更节省空间
  • 行高 22 - 比默认的 14 大,提高了可读性,特别是对于较长的文本

标签的设计

tag: {backgroundColor: '#2a475e', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, marginRight: 8, marginBottom: 8},

这里的设计考量:

  • 圆角 - borderRadius: 16 使标签看起来更现代
  • 水平 padding 大于垂直 padding - 这样标签看起来更"宽",更容易点击
  • 间距 - marginRightmarginBottom 确保标签之间有适当的间距

处理不同的游戏类型

免费游戏

免费游戏没有 price_overview 字段,所以我们需要特殊处理:

{game.price_overview ? (
  <Text style={styles.price}>{game.price_overview.final_formatted}</Text>
) : (
  <Text style={styles.price}>免费游玩</Text>
)}

这里做了什么: 检查 price_overview 是否存在。如果存在,显示价格;否则显示"免费游玩"。

打折游戏

打折游戏需要显示折扣徽章:

{game.price_overview.discount_percent > 0 && (
  <View style={styles.discountBadge}>
    <Text style={styles.discountText}>-{game.price_overview.discount_percent}%</Text>
  </View>
)}

这里做了什么: 只有当折扣大于 0 时,才显示折扣徽章。这样可以避免显示"-0%"这样的无意义信息。

即将推出的游戏

即将推出的游戏可能没有定价,也可能有预购价格。我们的代码已经能正确处理这两种情况,因为我们用了 price_overview ? 来判断。

数据安全性的考虑

Steam API 返回的数据结构比较复杂,有时候某些字段可能不存在。我们用了大量的可选链操作符(?.)和逻辑或(||)来处理:

// 安全地提取嵌套数据
setGame(details?.[selectedAppId]?.data);

// 提供默认值
<Text>{game.developers?.join(', ') || '未知'}</Text>

// 条件渲染
{game.price_overview ? (
  <Text>{game.price_overview.final_formatted}</Text>
) : (
  <Text>免费游玩</Text>
)}

这样做的好处:

  • 不会崩溃 - 即使 API 返回的数据不完整,页面也不会崩溃
  • 用户体验好 - 用户会看到"未知"或默认值,而不是 undefined
  • 代码健壮 - 能够处理各种异常情况

性能优化的思考

图片加载的优化

Steam 的游戏封面图片都很大(通常 1920x1080),直接加载可能会影响性能。在生产环境中,可以考虑:

  • 图片缓存 - React Native 的 Image 组件已经有内置的缓存机制,但可以进一步优化
  • 图片压缩 - 在服务器端压缩图片,或者使用 CDN 的图片优化服务
  • 占位图 - 加载前显示一个占位图,提高用户体验

数据缓存的考虑

当前的实现每次进入详情页都会重新请求数据。如果用户频繁切换游戏,这会产生大量请求。可以考虑加一个简单的缓存机制:

const cache = new Map<number, any>();

useEffect(() => {
  if (!selectedAppId) return;
  
  // 检查缓存
  if (cache.has(selectedAppId)) {
    setGame(cache.get(selectedAppId));
    setLoading(false);
    return;
  }
  
  // 请求数据
  getAppDetails(selectedAppId).then(details => {
    const data = details?.[selectedAppId]?.data;
    cache.set(selectedAppId, data);
    setGame(data);
    setLoading(false);
  });
}, [selectedAppId]);

这里做了什么:

  • 缓存检查 - 先检查数据是否已经在缓存中
  • 缓存命中 - 如果在缓存中,直接使用缓存数据,不需要请求
  • 缓存存储 - 如果不在缓存中,请求数据后存储到缓存中

这样可以显著减少网络请求,提高应用的响应速度。

错误处理的完善

当前的错误处理比较简单,只是把 loading 设为 false。更好的做法是区分不同的错误类型:

const [error, setError] = useState<string | null>(null);

useEffect(() => {
  Promise.all([...])
    .then(([details, players]) => {
      if (!details?.[selectedAppId]?.data) {
        setError('游戏数据不存在');
        return;
      }
      setGame(details[selectedAppId].data);
      setPlayerCount(players?.response?.player_count);
    })
    .catch(err => {
      setError('加载失败,请检查网络连接');
      console.error(err);
    })
    .finally(() => setLoading(false));
}, [selectedAppId]);

这里做了什么:

  • 数据验证 - 检查返回的数据是否有效
  • 错误分类 - 区分"数据不存在"和"网络错误"
  • 用户提示 - 显示有意义的错误信息,而不是技术性的错误

然后在渲染时显示错误信息:

{error && (
  <View style={styles.errorContainer}>
    <Text style={styles.errorText}>{error}</Text>
    <TouchableOpacity onPress={() => /* 重试 */}>
      <Text style={styles.retryText}>点击重试</Text>
    </TouchableOpacity>
  </View>
)}

这样用户就能了解发生了什么,并且可以选择重试。

后续页面的预留

快捷操作按钮指向的五个页面(新闻、成就、截图、视频、评测)还没有实现。这些页面会在后续的文章中逐一完成。

每个页面都会遵循类似的模式:

  • 获取 ID - 从 AppContext 获取 selectedAppId
  • 请求数据 - 调用对应的 API 获取数据
  • 展示数据 - 将数据渲染成列表或详情

这样可以保持代码的一致性和可维护性。

小结

游戏详情页是整个应用的信息中心。通过这个页面,我们学到了:

  • 如何聚合多个数据源 - 使用 Promise.all() 并行调用多个 API
  • 如何安全处理复杂数据 - 使用可选链和默认值
  • 如何设计清晰的信息架构 - 从重要到次要的信息排列
  • 如何集成应用功能 - 收藏、导航等
  • 如何考虑用户体验 - 加载状态、错误处理、性能优化

下一篇我们来实现游戏新闻页面,这是快捷操作中的第一个。新闻页面会展示与游戏相关的最新资讯,涉及新闻列表的加载和展示。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐