rn_for_openharmony_steam资讯app实战-游戏标签实现
React Native OpenHarmony 实战:Steam 标签页实现 本文介绍了使用 React Native for OpenHarmony 开发 Steam 资讯 App 的标签页面实现方案。该页面通过动态提取游戏标签并统计出现频率,展示最热门的游戏特征标签。 核心实现要点: 从19款热门游戏详情中动态提取标签数据,而非静态维护 使用对象统计标签频率并转换为排序数组 实现实时搜索功能
React Native for OpenHarmony 实战:Steam 资讯 App 游戏标签页面
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam
标签是 Steam 上另一种重要的游戏分类方式。与游戏类型(RPG、射击等)不同,标签是更细致的特征标记(单人、多人、支持手柄等)。由于标签数量众多且不断变化,我们需要从游戏详情中动态提取标签,而不是手动维护。
标签数据的动态提取
Steam 没有直接的标签查询 API,但每个游戏的详情中都包含 tags 字段。我们的策略是:
- 维护一个热门游戏列表
- 获取这些游戏的详情
- 从详情中提取标签并统计频率
- 显示最热门的标签
首先定义热门游戏列表:
export const POPULAR_GAMES_FOR_TAGS = [
730, 570, 440, 578080, 1172470, 271590, 1245620, 1091500, 892970, 1599340,
252490, 413150, 322330, 105600, 1174180, 814380, 374320, 289070, 1086940,
];
这个列表的设计意图:
- 包含了 Steam 上最热门的 19 个游戏,涵盖了各种类型
- 从这些游戏中提取标签,可以得到最常见的标签组合
- 这些标签代表了大多数玩家关心的游戏特征
- 列表中的游戏数量可以根据需要调整,更多的游戏会得到更全面的标签数据
标签页面的状态管理
标签页面需要管理的状态包括:
export const TagsScreen = () => {
const {navigate, setSelectedTag} = useApp();
const [tags, setTags] = useState<Array<{name: string, count: number}>>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filteredTags, setFilteredTags] = useState<Array<{name: string, count: number}>>([]);
状态的含义和用途:
tags- 存储所有提取出来的标签,每个标签对象包含名称和出现次数。这是原始的、未过滤的标签列表loading- 控制加载状态,在获取标签数据时设为 true,完成后设为 falsesearchQuery- 存储用户在搜索框中输入的内容,用于实时过滤标签filteredTags- 存储过滤后的标签列表,根据搜索词动态更新。这样可以保留原始数据,方便用户清空搜索时恢复
这样的状态设计让搜索功能很容易实现,因为原始数据和过滤数据是分离的。
标签数据的加载
页面加载时,需要获取热门游戏的详情,然后提取标签:
useEffect(() => {
const loadTags = async () => {
setLoading(true);
try {
// 获取所有热门游戏的详情
const gameDetails = await Promise.all(
POPULAR_GAMES_FOR_TAGS.map(appId => getAppDetails(appId))
);
// 统计标签
const tagCount: Record<string, number> = {};
gameDetails.forEach(detail => {
Object.values(detail).forEach((game: any) => {
if (game.data?.tags) {
game.data.tags.forEach((tag: string) => {
tagCount[tag] = (tagCount[tag] || 0) + 1;
});
}
});
});
// 转换为数组并排序
const tagArray = Object.entries(tagCount)
.map(([name, count]) => ({name, count}))
.sort((a, b) => b.count - a.count)
.slice(0, 50); // 只保留前 50 个标签
setTags(tagArray);
setFilteredTags(tagArray);
} catch (error) {
console.error('Error loading tags:', error);
} finally {
setLoading(false);
}
};
loadTags();
}, []);
这里的处理流程详解:
- 并行获取 - 用
Promise.all()同时发起所有游戏详情的请求。这比逐个请求快得多,因为网络请求可以并行进行 - 遍历游戏 - 对每个游戏的详情进行遍历,检查是否存在
tags字段 - 统计标签 - 用一个对象
tagCount来记录每个标签出现的次数。当遇到一个标签时,就把它的计数加 1 - 数据转换 - 将对象转换成数组,这样可以进行排序。每个元素是
{name: 标签名, count: 出现次数} - 排序 - 按出现次数从高到低排序,这样最热门的标签会排在前面
- 限制数量 - 只保留前 50 个标签,避免列表过长。这个数字可以根据需要调整
- 更新状态 - 将处理后的标签列表存储到
tags和filteredTags中
这个实现的关键是用对象来统计标签频率,然后转换成数组进行排序。这样可以高效地处理大量标签数据。
这样的实现让标签数据是动态的,不需要手动维护。
标签搜索的实现
用户可以搜索标签,快速找到想要的标签:
const handleSearchChange = (text: string) => {
setSearchQuery(text);
if (text.length === 0) {
setFilteredTags(tags);
} else {
const filtered = tags.filter(tag =>
tag.name.toLowerCase().includes(text.toLowerCase())
);
setFilteredTags(filtered);
}
};
搜索功能的实现细节:
- 实时过滤 - 用户每输入一个字符,就立即调用
filter()方法过滤标签列表 - 大小写不敏感 - 用
toLowerCase()将搜索词和标签名都转换成小写,这样搜索"RPG"和"rpg"都能找到 - 空搜索处理 - 如果搜索框为空(长度为 0),直接显示所有标签,不需要过滤
- 模糊匹配 - 用
includes()进行模糊匹配,用户输入"单"就能找到"单人"标签
这样的实现让用户可以快速找到想要的标签,即使标签列表很长。
标签卡片的设计
标签卡片需要显示标签名称和出现次数:
const TagCard = ({tag}: {tag: {name: string, count: number}}) => (
<TouchableOpacity
style={styles.tagCard}
onPress={() => {
setSelectedTag(tag.name);
navigate('tagGames');
}}
>
<Text style={styles.tagName}>{tag.name}</Text>
<Text style={styles.tagCount}>{tag.count} 个游戏</Text>
</TouchableOpacity>
);
卡片设计的考虑:
- 标签名称 - 显示标签的文字,比如"单人"、“多人”、"支持手柄"等
- 游戏数量 - 显示有多少个游戏拥有这个标签。这个信息很重要,因为它告诉用户这个标签的热度
- 点击处理 - 点击卡片时,先把标签名称存储到全局状态,然后导航到标签游戏列表页面
- 视觉反馈 - 用
TouchableOpacity包裹,这样点击时会有透明度变化的反馈
这样的设计让用户可以一眼看出哪些标签最热门,并快速进入标签游戏列表。
标签列表的渲染
标签列表用网格布局展示,这样可以充分利用屏幕空间:
<View style={styles.tagsGrid}>
{filteredTags.map((tag) => (
<View key={tag.name} style={styles.tagItem}>
<TagCard
tag={tag}
onPress={() => {
setSelectedTag(tag.name);
navigate('tagGames');
}}
/>
</View>
))}
</View>
网格布局的设计:
- flexWrap: ‘wrap’ - 让标签卡片自动换行,当一行放不下时就换到下一行
- justifyContent: ‘space-between’ - 让卡片均匀分布在屏幕宽度上,左右两边都有间距
- width: ‘48%’ - 每个卡片占屏幕宽度的 48%,这样一行可以显示 2 个卡片,中间有间距
- key={tag.name} - 用标签名作为 key,确保列表项的唯一性
这样的布局充分利用了屏幕空间,用户可以快速浏览所有标签。相比列表布局,网格布局可以显示更多的标签。
搜索框的实现
标签页面顶部有一个搜索框,让用户可以快速找到想要的标签:
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="搜索标签..."
placeholderTextColor="#8f98a0"
value={searchQuery}
onChangeText={handleSearchChange}
/>
{searchQuery.length > 0 && (
<TouchableOpacity
style={styles.clearBtn}
onPress={() => {
setSearchQuery('');
setFilteredTags(tags);
}}
>
<Text style={styles.clearBtnText}>✕</Text>
</TouchableOpacity>
)}
</View>
搜索框的实现细节:
- TextInput 组件 - 用于接收用户输入,
onChangeText事件会实时触发搜索过滤 - placeholder - 提示用户这是一个搜索框,可以搜索标签
- 清空按钮 - 只在用户输入了内容时显示(
searchQuery.length > 0) - 清空逻辑 - 点击清空按钮时,同时清空搜索词和恢复过滤列表到原始状态
- 样式 - 搜索框使用 Steam 的深色主题,与整个应用保持一致
这样的设计让用户可以快速清空搜索,而不需要逐个删除字符。
完整页面代码
现在把所有部分组合在一起:
import React, {useEffect, useState} from 'react';
import {View, Text, TextInput, TouchableOpacity, ScrollView, 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, POPULAR_GAMES_FOR_TAGS} from '../api/steam';
export const TagsScreen = () => {
const {navigate, setSelectedTag} = useApp();
const [tags, setTags] = useState<Array<{name: string, count: number}>>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filteredTags, setFilteredTags] = useState<Array<{name: string, count: number}>>([]);
const handleSearchChange = (text: string) => {
setSearchQuery(text);
if (text.length === 0) {
setFilteredTags(tags);
} else {
const filtered = tags.filter(tag =>
tag.name.toLowerCase().includes(text.toLowerCase())
);
setFilteredTags(filtered);
}
};
const TagCard = ({tag}: {tag: {name: string, count: number}}) => (
<TouchableOpacity
style={styles.tagCard}
onPress={() => {
setSelectedTag(tag.name);
navigate('tagGames');
}}
>
<Text style={styles.tagName}>{tag.name}</Text>
<Text style={styles.tagCount}>{tag.count} 个游戏</Text>
</TouchableOpacity>
);
useEffect(() => {
const loadTags = async () => {
setLoading(true);
try {
const gameDetails = await Promise.all(
POPULAR_GAMES_FOR_TAGS.map(appId => getAppDetails(appId))
);
const tagCount: Record<string, number> = {};
gameDetails.forEach(detail => {
Object.values(detail).forEach((game: any) => {
if (game.data?.tags) {
game.data.tags.forEach((tag: string) => {
tagCount[tag] = (tagCount[tag] || 0) + 1;
});
}
});
});
const tagArray = Object.entries(tagCount)
.map(([name, count]) => ({name, count}))
.sort((a, b) => b.count - a.count)
.slice(0, 50);
setTags(tagArray);
setFilteredTags(tagArray);
} catch (error) {
console.error('Error loading tags:', error);
} finally {
setLoading(false);
}
};
loadTags();
}, []);
if (loading) {
return (
<View style={styles.container}>
<Header title="游戏标签" showBack={false} />
<Loading />
<TabBar />
</View>
);
}
return (
<View style={styles.container}>
<Header title="游戏标签" showBack={false} />
<View style={styles.searchContainer}>
<TextInput
style={styles.searchInput}
placeholder="搜索标签..."
placeholderTextColor="#8f98a0"
value={searchQuery}
onChangeText={handleSearchChange}
/>
{searchQuery.length > 0 && (
<TouchableOpacity
style={styles.clearBtn}
onPress={() => {
setSearchQuery('');
setFilteredTags(tags);
}}
>
<Text style={styles.clearBtnText}>✕</Text>
</TouchableOpacity>
)}
</View>
<ScrollView style={styles.content}>
{filteredTags.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>未找到相关标签</Text>
</View>
) : (
<View style={styles.tagsGrid}>
{filteredTags.map((tag) => (
<View key={tag.name} style={styles.tagItem}>
<TagCard tag={tag} />
</View>
))}
</View>
)}
</ScrollView>
<TabBar />
</View>
);
};
const styles = StyleSheet.create({
container: {flex: 1, backgroundColor: '#171a21'},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: '#1b2838',
borderBottomWidth: 1,
borderBottomColor: '#2a475e',
},
searchInput: {
flex: 1,
height: 40,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: '#2a475e',
color: '#acdbf5',
fontSize: 14,
},
clearBtn: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
clearBtnText: {fontSize: 20, color: '#8f98a0'},
content: {flex: 1, padding: 12},
tagsGrid: {flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between'},
tagItem: {width: '48%', marginBottom: 12},
tagCard: {
padding: 12,
backgroundColor: '#1b2838',
borderRadius: 8,
borderWidth: 1,
borderColor: '#2a475e',
},
tagName: {fontSize: 14, fontWeight: '600', color: '#acdbf5', marginBottom: 4},
tagCount: {fontSize: 12, color: '#8f98a0'},
emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center', paddingVertical: 60},
emptyText: {fontSize: 16, color: '#8f98a0'},
});
标签数据的动态性
这个实现的一个优点是标签数据是动态的。每次用户进入标签页面时,都会重新加载标签数据。这意味着:
- 如果 Steam 上新增了热门游戏,新游戏的标签会自动出现
- 如果某个标签变得不热门,它的排名会下降
- 标签列表总是最新的,不需要手动更新
性能优化建议
加载标签数据需要请求多个游戏的详情,这可能会比较耗时。有几个优化方案:
缓存标签数据 - 可以在本地缓存标签数据,下次进入标签页面时直接使用缓存,而不是重新加载。可以设置一个过期时间,比如 24 小时后重新加载。
后台更新 - 可以在应用启动时后台更新标签数据,这样用户进入标签页面时就能立即看到数据,不需要等待。
分批加载 - 如果热门游戏列表很长,可以分批加载,先加载前 10 个游戏,然后逐步加载更多。
小结
游戏标签页面展示了一个有趣的设计思路:
- 动态数据 - 标签数据不是手动维护的,而是从游戏详情中动态提取的
- 数据聚合 - 从多个游戏的数据中聚合出标签信息
- 排序和过滤 - 按热度排序标签,支持搜索过滤
- 用户友好 - 显示每个标签的热度,帮助用户了解标签的受欢迎程度
这种设计让应用更加灵活,能够自动适应数据的变化。
下一篇我们来实现标签游戏列表页面,展示某个标签下的所有游戏。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)