rn_for_openharmony_steam资讯app实战-游戏详情实现
本文介绍了使用 React Native for OpenHarmony 开发 Steam 资讯 App 游戏详情页面的实战经验。文章重点讲解了如何聚合多个 Steam API 数据(包括游戏基本信息、在线玩家数、成就系统等),并详细分析了页面布局与核心代码实现。关键技术点包括:使用 Promise.all 进行并行 API 请求优化性能、合理处理加载和错误状态、实现收藏功能、展示价格折扣信息、设
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>
这里做了什么:
- 数组处理 -
developers和publishers是数组,用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 - 这样标签看起来更"宽",更容易点击
- 间距 -
marginRight和marginBottom确保标签之间有适当的间距
处理不同的游戏类型
免费游戏
免费游戏没有 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
更多推荐



所有评论(0)