React Native for OpenHarmony 实战:Steam 资讯 App 分类游戏列表页面

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

从分类页面选择一个分类后,用户会进入分类游戏列表页面。这个页面展示该分类下的所有游戏。相比前面的精选、特惠等页面,分类游戏列表页面有个特殊的地方:需要根据分类 ID 动态加载游戏数据。
请添加图片描述

问题:Steam 没有分类查询 API

在开发这个功能时,我遇到了一个问题。Steam 官方没有提供按分类查询游戏的 API。这意味着我们不能直接调用一个接口说"给我所有 RPG 游戏"。

这是很多开发者都会遇到的问题。有几种解决方案:

第一种:调用第三方 API

有些网站(比如 SteamSpy)提供了 Steam 游戏数据的 API。但这样做的问题是依赖第三方服务,如果第三方服务宕机或改变 API,我们的应用就会受影响。而且第三方 API 可能有速率限制,不适合频繁调用。

第二种:爬虫爬取数据

写一个爬虫程序定期爬取 Steam 网站的数据,然后存储到数据库。这样做的好处是数据最新,但缺点是实现复杂,而且可能违反 Steam 的服务条款。

第三种:本地维护分类游戏列表

手动维护每个分类下的热门游戏列表。这是最简单的方案,虽然需要定期更新,但对于一个小型应用来说完全够用。

我选择了第三种方案。这样做的好处是:

  • 不依赖第三方服务
  • 实现简单,代码清晰
  • 可以完全控制数据
  • 性能最好,因为数据都在本地

定义分类游戏数据

在 API 文件中定义每个分类下的游戏列表。这里用一个对象来存储,键是分类 ID,值是游戏 AppId 数组:

export const CATEGORY_GAMES: Record<string, number[]> = {
  action: [730, 570, 578080, 271590, 1091500, 1172470, 252490, 1174180],
  adventure: [1245620, 1086940, 814380, 374320, 289070, 322330, 105600, 1599340],
  rpg: [1245620, 1091500, 1086940, 814380, 374320, 289070, 1174180, 1599340],
  strategy: [570, 289070, 322330, 1599340, 1172470, 578080, 271590, 1245620],
  simulation: [252490, 892970, 413150, 1174180, 1091500, 271590, 578080, 1245620],
  puzzle: [105600, 322330, 413150, 892970, 1245620, 1086940, 814380, 374320],
  casual: [413150, 892970, 322330, 105600, 1245620, 1086940, 1091500, 814380],
  sports: [578080, 1172470, 271590, 1245620, 1091500, 1086940, 814380, 374320],
  racing: [271590, 578080, 1172470, 1245620, 1091500, 1086940, 814380, 374320],
  indie: [892970, 413150, 322330, 105600, 1245620, 1086940, 1091500, 814380],
};

为什么这样设计:

  • Record<string, number[]> 是 TypeScript 的类型,表示一个对象,键是字符串,值是数字数组
  • 每个分类下有 8 个游戏,这个数量可以根据需要调整
  • 游戏的顺序代表热度,热门的游戏放在前面

这样的数据结构很容易维护。如果要更新某个分类的游戏,只需要修改对应的数组即可。

页面的核心逻辑

分类游戏列表页面的核心是根据分类 ID 加载游戏数据。这里用 useEffect 来处理:

useEffect(() => {
  if (!selectedCategory) return;
  
  const loadCategoryGames = async () => {
    setLoading(true);
    try {
      const appIds = CATEGORY_GAMES[selectedCategory] || [];
      
      // 并行获取所有游戏的详情
      const gameDetails = await Promise.all(
        appIds.map(appId => getAppDetails(appId))
      );
      
      // 提取游戏数据
      const gamesData = gameDetails
        .map((detail, index) => {
          const appId = appIds[index];
          const data = detail?.[appId]?.data;
          return data ? {id: appId, ...data} : null;
        })
        .filter(game => game !== null);
      
      setGames(gamesData);
    } catch (error) {
      console.error('Error loading category games:', error);
    } finally {
      setLoading(false);
    }
  };
  
  loadCategoryGames();
}, [selectedCategory]);

这里的关键步骤:

  1. 检查分类 - 如果没有选中分类,直接返回
  2. 获取 AppId 列表 - 从 CATEGORY_GAMES 中查找该分类的游戏 AppId
  3. 并行请求 - 用 Promise.all() 同时请求所有游戏的详情。这比串行请求快得多
  4. 数据提取 - 从 API 响应中提取游戏数据,并添加 id 字段
  5. 数据过滤 - 过滤掉获取失败的游戏(值为 null 的项)

为什么用 Promise.all() 而不是逐个请求?因为如果分类下有 8 个游戏,逐个请求需要等待 8 次网络往返。而 Promise.all() 可以同时发起 8 个请求,总时间只是最慢的那个请求的时间。这样可以大大提高加载速度。

排序功能的实现

用户可以按不同方式排序游戏。这里实现一个排序函数:

const getSortedGames = () => {
  let sorted = [...games];
  
  if (sortBy === 'price_asc') {
    sorted.sort((a, b) => {
      const priceA = a.price_overview?.final_price || 0;
      const priceB = b.price_overview?.final_price || 0;
      return priceA - priceB;
    });
  } else if (sortBy === 'price_desc') {
    sorted.sort((a, b) => {
      const priceA = a.price_overview?.final_price || 0;
      const priceB = b.price_overview?.final_price || 0;
      return priceB - priceA;
    });
  }
  
  return sorted;
};

排序的细节:

  • 创建一个副本而不是直接修改原数组,这样可以保留原始顺序
  • price_overview 中提取 final_price,如果不存在则用 0
  • 升序用 a - b,降序用 b - a

这样的实现让排序功能很容易扩展。如果要加新的排序方式(比如按评分排序),只需要在这个函数中添加新的条件即可。

排序按钮的 UI

在页面顶部显示排序按钮,让用户可以快速改变排序方式:

<View style={styles.toolbar}>
  <View style={styles.sortButtons}>
    <TouchableOpacity
      style={[styles.sortBtn, sortBy === 'default' && styles.sortBtnActive]}
      onPress={() => setSortBy('default')}
    >
      <Text style={styles.sortBtnText}>默认</Text>
    </TouchableOpacity>
    <TouchableOpacity
      style={[styles.sortBtn, sortBy === 'price_asc' && styles.sortBtnActive]}
      onPress={() => setSortBy('price_asc')}
    >
      <Text style={styles.sortBtnText}>价格低</Text>
    </TouchableOpacity>
    <TouchableOpacity
      style={[styles.sortBtn, sortBy === 'price_desc' && styles.sortBtnActive]}
      onPress={() => setSortBy('price_desc')}
    >
      <Text style={styles.sortBtnText}>价格高</Text>
    </TouchableOpacity>
  </View>
</View>

UI 的设计:

  • 三个按钮分别对应三种排序方式
  • 当前选中的排序方式用 sortBtnActive 样式高亮显示
  • 点击按钮时更新 sortBy 状态,页面会自动重新渲染

这样的设计让用户可以直观地看到当前的排序方式,并快速改变。

游戏列表的渲染

游戏列表用 FlatList 渲染,支持分页加载。这里只展示关键部分:

<FlatList
  data={getSortedGames().slice(0, (pageIndex + 1) * 10)}
  keyExtractor={(item) => item.id.toString()}
  renderItem={({item}) => (
    <TouchableOpacity
      style={styles.gameItem}
      onPress={() => {
        setSelectedAppId(item.id);
        addToHistory(item.id);
        navigate('gameDetail');
      }}
    >
      <Image
        source={{uri: item.header_image}}
        style={styles.gameImage}
        resizeMode="cover"
      />
      <View style={styles.gameInfo}>
        <Text style={styles.gameName} numberOfLines={2}>{item.name}</Text>
        <Text style={styles.gamePrice}>
          {item.price_overview?.final_price 
            ? `¥${(item.price_overview.final_price / 100).toFixed(2)}`
            : '免费'}
        </Text>
      </View>
    </TouchableOpacity>
  )}
  onEndReached={() => {
    const sortedGames = getSortedGames();
    if (pageIndex < Math.ceil(sortedGames.length / 10) - 1) {
      setPageIndex(prev => prev + 1);
    }
  }}
  onEndReachedThreshold={0.5}
  ListEmptyComponent={
    <View style={styles.emptyContainer}>
      <Text style={styles.emptyText}>该分类暂无游戏</Text>
    </View>
  }
/>

列表的关键点:

  • 分页加载 - 每次显示 10 条游戏,滚动到底部时加载下一页
  • 排序应用 - 用 getSortedGames() 获取排序后的游戏列表
  • 游戏卡片 - 显示游戏封面、名称和价格
  • 点击导航 - 点击游戏卡片时跳转到游戏详情页
  • 价格格式化 - 将价格从分转换成元,保留两位小数

获取分类名称

需要一个辅助函数来根据分类 ID 获取分类名称,用于显示在 Header 中:

const getCategoryName = (categoryId: string | null) => {
  if (!categoryId) return '游戏列表';
  
  const category = GAME_CATEGORIES.find(cat => cat.id === categoryId);
  return category?.name || '游戏列表';
};

这个函数的作用:

  • 根据分类 ID 查找对应的分类对象
  • 返回分类的名称
  • 如果找不到,返回默认名称"游戏列表"

这样的实现让代码更健壮,即使分类 ID 不存在也不会导致应用崩溃。

完整页面代码

现在把所有部分组合在一起,看看完整的分类游戏列表页面:

import React, {useEffect, useState} from 'react';
import {View, Text, FlatList, TouchableOpacity, Image, StyleSheet} from 'react-native';
import {Header} from '../components/Header';
import {TabBar} from '../components/TabBar';
import {Loading} from '../components/Loading';
import {useApp} from '../store/AppContext';
import {getAppDetails, CATEGORY_GAMES, GAME_CATEGORIES} from '../api/steam';

export const CategoryGamesScreen = () => {
  const {selectedCategory, navigate, setSelectedAppId, addToHistory} = useApp();
  const [games, setGames] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [sortBy, setSortBy] = useState<'default' | 'price_asc' | 'price_desc'>('default');
  const [pageIndex, setPageIndex] = useState(0);

  const getCategoryName = (categoryId: string | null) => {
    if (!categoryId) return '游戏列表';
    const category = GAME_CATEGORIES.find(cat => cat.id === categoryId);
    return category?.name || '游戏列表';
  };

  const getSortedGames = () => {
    let sorted = [...games];
    
    if (sortBy === 'price_asc') {
      sorted.sort((a, b) => {
        const priceA = a.price_overview?.final_price || 0;
        const priceB = b.price_overview?.final_price || 0;
        return priceA - priceB;
      });
    } else if (sortBy === 'price_desc') {
      sorted.sort((a, b) => {
        const priceA = a.price_overview?.final_price || 0;
        const priceB = b.price_overview?.final_price || 0;
        return priceB - priceA;
      });
    }
    
    return sorted;
  };

  useEffect(() => {
    if (!selectedCategory) return;
    
    const loadCategoryGames = async () => {
      setLoading(true);
      try {
        const appIds = CATEGORY_GAMES[selectedCategory] || [];
        
        const gameDetails = await Promise.all(
          appIds.map(appId => getAppDetails(appId))
        );
        
        const gamesData = gameDetails
          .map((detail, index) => {
            const appId = appIds[index];
            const data = detail?.[appId]?.data;
            return data ? {id: appId, ...data} : null;
          })
          .filter(game => game !== null);
        
        setGames(gamesData);
      } catch (error) {
        console.error('Error loading category games:', error);
      } finally {
        setLoading(false);
      }
    };
    
    loadCategoryGames();
  }, [selectedCategory]);

  if (loading) {
    return (
      <View style={styles.container}>
        <Header title="分类游戏" showBack />
        <Loading />
        <TabBar />
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <Header title={getCategoryName(selectedCategory)} showBack />
      
      <View style={styles.toolbar}>
        <View style={styles.sortButtons}>
          <TouchableOpacity
            style={[styles.sortBtn, sortBy === 'default' && styles.sortBtnActive]}
            onPress={() => setSortBy('default')}
          >
            <Text style={styles.sortBtnText}>默认</Text>
          </TouchableOpacity>
          <TouchableOpacity
            style={[styles.sortBtn, sortBy === 'price_asc' && styles.sortBtnActive]}
            onPress={() => setSortBy('price_asc')}
          >
            <Text style={styles.sortBtnText}>价格低</Text>
          </TouchableOpacity>
          <TouchableOpacity
            style={[styles.sortBtn, sortBy === 'price_desc' && styles.sortBtnActive]}
            onPress={() => setSortBy('price_desc')}
          >
            <Text style={styles.sortBtnText}>价格高</Text>
          </TouchableOpacity>
        </View>
      </View>

      <FlatList
        data={getSortedGames().slice(0, (pageIndex + 1) * 10)}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({item}) => (
          <TouchableOpacity
            style={styles.gameItem}
            onPress={() => {
              setSelectedAppId(item.id);
              addToHistory(item.id);
              navigate('gameDetail');
            }}
          >
            <Image
              source={{uri: item.header_image}}
              style={styles.gameImage}
              resizeMode="cover"
            />
            <View style={styles.gameInfo}>
              <Text style={styles.gameName} numberOfLines={2}>{item.name}</Text>
              <Text style={styles.gamePrice}>
                {item.price_overview?.final_price 
                  ? `¥${(item.price_overview.final_price / 100).toFixed(2)}`
                  : '免费'}
              </Text>
            </View>
          </TouchableOpacity>
        )}
        onEndReached={() => {
          const sortedGames = getSortedGames();
          if (pageIndex < Math.ceil(sortedGames.length / 10) - 1) {
            setPageIndex(prev => prev + 1);
          }
        }}
        onEndReachedThreshold={0.5}
        ListEmptyComponent={
          <View style={styles.emptyContainer}>
            <Text style={styles.emptyText}>该分类暂无游戏</Text>
          </View>
        }
      />

      <TabBar />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {flex: 1, backgroundColor: '#171a21'},
  toolbar: {
    padding: 12,
    backgroundColor: '#1b2838',
    borderBottomWidth: 1,
    borderBottomColor: '#2a475e',
  },
  sortButtons: {flexDirection: 'row', justifyContent: 'space-around'},
  sortBtn: {
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderRadius: 4,
    backgroundColor: '#2a475e',
  },
  sortBtnActive: {backgroundColor: '#66c0f4'},
  sortBtnText: {fontSize: 12, color: '#acdbf5', fontWeight: '600'},
  gameItem: {
    flexDirection: 'row',
    padding: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#2a475e',
    backgroundColor: '#1b2838',
  },
  gameImage: {width: 80, height: 45, borderRadius: 4, marginRight: 12},
  gameInfo: {flex: 1, justifyContent: 'space-between'},
  gameName: {fontSize: 14, fontWeight: '600', color: '#acdbf5', marginBottom: 4},
  gamePrice: {fontSize: 12, color: '#66c0f4'},
  emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', paddingVertical: 60},
  emptyText: {fontSize: 16, color: '#8f98a0'},
});

性能优化的思考

在实际开发中,这个页面有几个可以优化的地方:

API 请求的批处理 - 当分类下有很多游戏时,一次性并行请求所有游戏的详情可能会导致网络拥堵。可以考虑分批请求,比如每次请求 5 个,然后等待完成后再请求下一批。

游戏数据的缓存 - 如果用户频繁切换分类,每次都重新请求游戏数据会很浪费。可以考虑缓存已经加载过的分类数据,这样用户再次进入该分类时就不需要重新加载。

分类游戏列表的更新 - 由于我们手动维护分类游戏列表,所以需要定期更新。可以考虑在应用启动时检查是否有新的游戏数据,如果有就更新本地列表。

小结

分类游戏列表页面虽然看起来简单,但涉及到了几个重要的开发技巧:

  • 解决 API 限制 - 当官方 API 不提供某个功能时,如何找到替代方案
  • 并行处理 - 用 Promise.all() 提高数据加载速度
  • 排序功能 - 实现灵活的排序逻辑
  • 分页加载 - 优化大列表的性能
  • 页面联动 - 多个页面之间的数据传递

这些都是实际开发中常见的需求。掌握这些技巧对于开发高质量的应用很重要。


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

Logo

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

更多推荐