rn_for_openharmony_steam资讯app实战-分类游戏列表实现
本文介绍了使用React Native开发OpenHarmony版Steam资讯App分类游戏列表页面的实现方案。针对Steam没有分类查询API的问题,作者选择本地维护分类游戏列表的方式,手动定义每个分类下的热门游戏ID数组。页面核心逻辑通过useEffect根据分类ID并行加载游戏详情数据,并实现按价格排序功能。UI部分提供直观的排序按钮,使用FlatList渲染游戏列表并支持分页加载。该方案
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]);
这里的关键步骤:
- 检查分类 - 如果没有选中分类,直接返回
- 获取 AppId 列表 - 从
CATEGORY_GAMES中查找该分类的游戏 AppId - 并行请求 - 用
Promise.all()同时请求所有游戏的详情。这比串行请求快得多 - 数据提取 - 从 API 响应中提取游戏数据,并添加
id字段 - 数据过滤 - 过滤掉获取失败的游戏(值为 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
更多推荐

所有评论(0)