rn_for_openharmony_steam资讯app实战-游戏成就实现
React Native for OpenHarmony 实战:Steam 游戏成就页面开发 本文介绍了使用 React Native for OpenHarmony 开发 Steam 游戏成就页面的关键实现。通过整合 Steam 的两个 API(GetSchemaForGame 和 GetGlobalAchievementPercentagesForApp),开发者可以获取游戏成就数据并进行聚合
React Native for OpenHarmony 实战:Steam 资讯 App 游戏成就页面
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam
游戏成就是衡量玩家游戏深度的重要指标。成就页面需要展示游戏的所有成就、玩家的达成情况和全球达成率。这个页面涉及两个 API 的数据聚合和复杂的数据处理。
成就 API 的数据结构
Steam 提供了两个与成就相关的 API:GetSchemaForGame 和 GetGlobalAchievementPercentagesForApp。
export const getGameSchema = async (appId: number) => {
const res = await fetch(
`${COMMUNITY_API}/ISteamUserStats/GetSchemaForGame/v2/?appid=${appId}`,
);
return res.json();
};
这里做了什么: 获取游戏的成就定义,包括每个成就的名称、描述、图标等。这个接口返回的是游戏的成就模板,不是玩家的成就数据。
export const getGlobalAchievements = async (appId: number) => {
const res = await fetch(
`${COMMUNITY_API}/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/?gameid=${appId}`,
);
return res.json();
};
这里做了什么: 获取全球玩家的成就达成率。这个接口返回每个成就被多少百分比的玩家达成过。
GetSchemaForGame 返回的数据结构:
{
"game": {
"gameName": "Counter-Strike 2",
"gameVersion": "1",
"achievements": [
{
"name": "ACHIEVEMENT_NAME",
"displayName": "成就名称",
"description": "成就描述",
"icon": "https://...",
"icongray": "https://...",
"hidden": 0,
"percentUnlocked": 0.5
}
]
}
}
关键字段说明:
displayName- 成就的显示名称description- 成就的详细描述icon- 已解锁时的图标icongray- 未解锁时的灰色图标hidden- 是否是隐藏成就(1 表示隐藏)percentUnlocked- 全球达成率(0-1 之间)
GetGlobalAchievementPercentagesForApp 返回的数据结构:
{
"achievementpercentages": {
"achievements": [
{
"name": "ACHIEVEMENT_NAME",
"percent": 50.5
}
]
}
}
这里做了什么: 返回每个成就的达成百分比。这个数据可以用来排序成就(最容易达成的在前)或显示难度指示。
数据聚合的策略
由于两个 API 返回的数据结构不同,需要将它们合并成一个统一的格式:
const mergeAchievements = (schema: any, percentages: any) => {
const percentMap = new Map();
percentages?.achievementpercentages?.achievements?.forEach((item: any) => {
percentMap.set(item.name, item.percent);
});
return schema?.game?.achievements?.map((achievement: any) => ({
...achievement,
globalPercent: percentMap.get(achievement.name) || 0,
})) || [];
};
这里做了什么:
- 创建 Map - 将百分比数据存储在 Map 中,便于快速查找
- 遍历成就 - 遍历成就列表,为每个成就添加全球达成率
- 返回合并数据 - 返回包含所有信息的成就列表
这样就得到了一个完整的成就列表,每个成就都包含定义信息和全球达成率。
页面结构设计
成就页面的布局需要展示大量信息:
顶部 是 Header 和成就统计信息(总成就数、已解锁数等)。
中间 是成就列表,可以按难度排序。
每条成就 显示图标、名称、描述和全球达成率。
隐藏成就 需要特殊处理,可能不显示名称和描述。
核心代码实现
组件初始化和数据加载
export const GameAchievementsScreen = () => {
const {selectedAppId} = useApp();
const [achievements, setAchievements] = useState<any[]>([]);
const [stats, setStats] = useState({total: 0, unlocked: 0});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'difficulty' | 'name'>('difficulty');
这里做了什么: 定义了成就列表、统计信息、加载状态和排序方式。sortBy 用来控制成就的排序(按难度或按名称)。
状态设计的考量: achievements 存储所有成就数据,stats 是一个对象,包含总成就数和已解锁数。这样设计可以避免多个状态变量,代码更清晰。sortBy 用 TypeScript 的联合类型限制只能是 ‘difficulty’ 或 ‘name’,提供了类型安全。
为什么分离统计信息: 虽然可以从 achievements 数组计算出统计信息,但分离出来作为单独的状态可以避免每次渲染时重复计算。这是一个性能优化的技巧。
并行加载两个 API
useEffect(() => {
if (!selectedAppId) return;
Promise.all([
getGameSchema(selectedAppId),
getGlobalAchievements(selectedAppId),
]).then(([schema, percentages]) => {
const merged = mergeAchievements(schema, percentages);
setAchievements(merged);
setStats({
total: merged.length,
unlocked: merged.filter((a: any) => a.percentUnlocked > 0).length,
});
setLoading(false);
}).catch(() => setLoading(false));
}, [selectedAppId]);
这里做了什么:
- 并行请求 - 使用
Promise.all()同时请求两个 API - 数据合并 - 调用
mergeAchievements()合并数据 - 统计计算 - 计算总成就数和已解锁的成就数
为什么用 Promise.all: 两个 API 请求是独立的,没有依赖关系,所以可以并行请求。这样总耗时是最慢的那个请求的时间,而不是两个请求时间的和。如果一个请求需要 1 秒,另一个需要 2 秒,并行请求总耗时是 2 秒,而串行请求需要 3 秒。
统计信息的计算: unlocked 是通过 filter() 计算的,只统计 percentUnlocked > 0 的成就。这里有个细节:percentUnlocked 是从 API 返回的数据中获取的,表示全球达成率,不是当前玩家的达成情况。所以这个统计实际上是"有人达成过的成就数",而不是"当前玩家达成的成就数"。
错误处理: 即使请求失败,也会把 loading 设为 false,这样用户不会一直看到加载动画。在实际项目中,应该添加错误提示。
成就排序
const sortedAchievements = [...achievements].sort((a, b) => {
if (sortBy === 'difficulty') {
// 按难度排序(达成率低的在前)
return a.globalPercent - b.globalPercent;
} else {
// 按名称排序
return a.displayName.localeCompare(b.displayName, 'zh-CN');
}
});
这里做了什么:
- 难度排序 - 达成率低的成就(更难的)排在前面
- 名称排序 - 按字母顺序排序,使用
localeCompare()支持中文排序 - 不修改原数组 - 使用
[...achievements]创建副本,避免修改原数据
为什么要创建副本: 直接修改 achievements 数组会导致原数据被改变。如果用户切换排序方式,再切换回来,数据顺序就不对了。创建副本可以避免这个问题。
localeCompare 的作用: 这个方法可以按照特定语言的规则比较字符串。'zh-CN' 表示中文排序。这样中文字符会按照拼音顺序排序,而不是按照 Unicode 编码顺序。
排序的性能: 每次 sortBy 变化时,都会重新计算 sortedAchievements。如果成就很多(比如 1000+),排序可能会有性能问题。可以考虑使用 useMemo 来缓存排序结果。
成就统计信息的展示
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statLabel}>总成就</Text>
<Text style={styles.statValue}>{stats.total}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>已解锁</Text>
<Text style={styles.statValue}>{stats.unlocked}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>完成度</Text>
<Text style={styles.statValue}>
{stats.total > 0 ? Math.round((stats.unlocked / stats.total) * 100) : 0}%
</Text>
</View>
</View>
这里做了什么:
- 三个统计项 - 显示总成就数、已解锁数和完成度百分比
- 百分比计算 - 用已解锁数除以总数,乘以 100 得到百分比
- 安全处理 - 检查
stats.total > 0避免除以零
布局设计: 三个统计项并排显示,使用 flexDirection: 'row' 和 justifyContent: 'space-around' 均匀分布。这样用户能一眼看到游戏的成就完成情况。
百分比的精度: 使用 Math.round() 四舍五入到整数。这样显示的百分比更简洁,比如 “87%” 而不是 “86.7%”。
零除法保护: 如果 stats.total 是 0(游戏没有成就),直接返回 0%,避免 NaN。这是一个常见的防御性编程技巧。
成就列表项的渲染
<FlatList
data={sortedAchievements}
keyExtractor={(item) => item.name}
renderItem={({item}) => (
<View style={styles.achievementItem}>
<Image
source={{uri: item.percentUnlocked > 0 ? item.icon : item.icongray}}
style={styles.achievementIcon}
/>
<View style={styles.achievementInfo}>
{item.hidden ? (
<>
<Text style={styles.achievementTitle}>隐藏成就</Text>
<Text style={styles.achievementDesc}>解锁后显示</Text>
</>
) : (
<>
<Text style={styles.achievementTitle}>{item.displayName}</Text>
<Text style={styles.achievementDesc}>{item.description}</Text>
</>
)}
</View>
<View style={styles.achievementPercent}>
<Text style={styles.percentText}>{item.globalPercent.toFixed(1)}%</Text>
</View>
</View>
)}
/>
这里做了什么:
- 图标选择 - 已解锁显示彩色图标,未解锁显示灰色图标
- 隐藏成就处理 - 隐藏成就不显示名称和描述,只显示"隐藏成就"
- 达成率显示 - 显示全球达成率,用
toFixed(1)保留一位小数
图标的逻辑: item.percentUnlocked > 0 表示有人达成过这个成就,所以显示彩色图标。否则显示灰色图标。这样用户能快速判断成就的达成情况。
隐藏成就的处理: Steam 有一种特殊的成就叫"隐藏成就",在达成前不会显示名称和描述。我们用 item.hidden 来判断,如果是隐藏成就,就显示"隐藏成就"和"解锁后显示"。
toFixed(1) 的作用: 这个方法保留一位小数。比如 50.5% 就显示为 “50.5%”,而不是 “50.50%”。
Fragment 的使用: 使用 <> 和 </> 是 Fragment 的简写,用来包裹多个元素而不增加额外的 DOM 节点。这样代码更简洁。
排序按钮
<View style={styles.sortContainer}>
<TouchableOpacity
style={[styles.sortBtn, sortBy === 'difficulty' && styles.sortBtnActive]}
onPress={() => setSortBy('difficulty')}
>
<Text style={styles.sortBtnText}>按难度</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.sortBtn, sortBy === 'name' && styles.sortBtnActive]}
onPress={() => setSortBy('name')}
>
<Text style={styles.sortBtnText}>按名称</Text>
</TouchableOpacity>
</View>
这里做了什么:
- 两个排序选项 - 难度和名称
- 活跃状态 - 当前选中的排序方式显示不同的样式
- 状态切换 - 点击按钮时更新
sortBy状态
样式数组的用法: [styles.sortBtn, sortBy === 'difficulty' && styles.sortBtnActive] 是一个常见的 React Native 模式。如果条件为真,就应用 sortBtnActive 样式,覆盖 sortBtn 中的相同属性。
条件渲染样式: 这样可以根据状态动态改变样式。当用户点击"按难度"按钮时,sortBy 变为 ‘difficulty’,条件为真,按钮显示活跃样式(比如背景色变蓝)。
用户反馈: 通过改变样式,用户能清楚地看到当前选中的排序方式。这是一个很好的 UX 设计。
成就难度的可视化
可以根据达成率显示难度指示:
const getDifficultyLevel = (percent: number) => {
if (percent > 50) return {label: '简单', color: '#4c6b22'};
if (percent > 20) return {label: '普通', color: '#2a475e'};
if (percent > 5) return {label: '困难', color: '#c23c2a'};
return {label: '极难', color: '#8b0000'};
};
这里做了什么: 根据全球达成率判断成就的难度等级,并返回对应的标签和颜色。
难度等级的划分: 这个划分是基于常见的游戏成就难度分布。大多数玩家能达成的成就(>50%)是简单的,只有少数玩家能达成的成就(<5%)是极难的。这个划分可以根据实际情况调整。
颜色的选择: 绿色表示简单,灰色表示普通,红色表示困难,深红色表示极难。这样用户能通过颜色快速判断成就的难度。
返回对象的设计: 返回一个对象包含 label 和 color,这样可以同时获取显示文本和颜色,代码更简洁。
在成就项中使用:
const difficulty = getDifficultyLevel(item.globalPercent);
<View style={[styles.difficultyBadge, {backgroundColor: difficulty.color}]}>
<Text style={styles.difficultyText}>{difficulty.label}</Text>
</View>
这里做了什么: 在成就项中显示难度徽章。通过动态设置背景色,不同难度的成就显示不同的颜色。
动态样式的应用: {backgroundColor: difficulty.color} 是一个内联样式,会覆盖 difficultyBadge 中的背景色。这样可以根据难度动态改变颜色。
成就分类
有些游戏的成就很多,可以按类型分类显示:
const groupAchievements = (achievements: any[]) => {
const groups: Record<string, any[]> = {};
achievements.forEach(achievement => {
const category = achievement.hidden ? '隐藏成就' : '普通成就';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(achievement);
});
return groups;
};
这里做了什么: 将成就按隐藏和普通分成两组。可以进一步扩展,按其他标准分类。
分组的原理: 使用一个对象 groups 来存储分组结果。键是分类名称,值是该分类下的成就数组。遍历所有成就,根据条件判断属于哪个分类,然后添加到对应的数组中。
Record 类型的作用: Record<string, any[]> 是 TypeScript 的类型定义,表示一个对象,键是字符串,值是数组。这样可以提供类型安全。
可扩展性: 这个函数很容易扩展。比如可以按成就的类型(比如"战斗"、"探索"等)分类,只需要改变 category 的计算逻辑即可。
然后在列表中显示分组:
{Object.entries(groupedAchievements).map(([category, items]) => (
<View key={category}>
<Text style={styles.categoryTitle}>{category}</Text>
{items.map(achievement => (
// 渲染成就项
))}
</View>
))}
这里做了什么: 遍历分组结果,为每个分类显示一个标题,然后显示该分类下的所有成就。
Object.entries 的作用: 这个方法将对象转换成键值对数组。比如 {a: 1, b: 2} 变成 [['a', 1], ['b', 2]]。这样可以用 map() 遍历。
嵌套 map 的性能: 这样做会有两层 map,性能可能不是最优的。如果成就很多,可以考虑使用 FlatList 的 sections 属性来实现分组列表。
成就搜索功能
用户可能想搜索特定的成就:
const [searchQuery, setSearchQuery] = useState('');
const filteredAchievements = sortedAchievements.filter(achievement => {
if (achievement.hidden) return false;
const query = searchQuery.toLowerCase();
return (
achievement.displayName.toLowerCase().includes(query) ||
achievement.description.toLowerCase().includes(query)
);
});
这里做了什么:
- 搜索框输入 - 用户输入搜索词
- 过滤成就 - 在成就名称和描述中搜索
- 隐藏成就排除 - 隐藏成就不参与搜索
搜索的逻辑: 使用 filter() 方法过滤成就。对于每个成就,检查名称或描述是否包含搜索词。使用 toLowerCase() 进行不区分大小写的比较。
隐藏成就的处理: 隐藏成就的名称和描述是隐藏的,所以不应该在搜索结果中显示。直接返回 false 排除隐藏成就。
搜索的性能: 每次用户输入时,都会重新过滤整个成就列表。如果成就很多(比如 1000+),这可能会有性能问题。可以考虑使用 useMemo 来缓存搜索结果。
在页面顶部添加搜索框:
<TextInput
style={styles.searchInput}
placeholder="搜索成就..."
placeholderTextColor="#8f98a0"
value={searchQuery}
onChangeText={setSearchQuery}
/>
这里做了什么: 创建一个文本输入框,用户可以输入搜索词。每次输入时,onChangeText 会被调用,更新 searchQuery 状态。
TextInput 的属性: placeholder 是输入框为空时显示的提示文字。placeholderTextColor 是提示文字的颜色。value 和 onChangeText 用来绑定状态。
成就详情的展示
点击成就可以显示更详细的信息:
const [selectedAchievement, setSelectedAchievement] = useState<any>(null);
<TouchableOpacity
style={styles.achievementItem}
onPress={() => setSelectedAchievement(item)}
>
{/* 成就内容 */}
</TouchableOpacity>
{selectedAchievement && (
<Modal visible={true} transparent={true}>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Image source={{uri: selectedAchievement.icon}} style={styles.modalIcon} />
<Text style={styles.modalTitle}>{selectedAchievement.displayName}</Text>
<Text style={styles.modalDesc}>{selectedAchievement.description}</Text>
<Text style={styles.modalPercent}>
全球达成率:{selectedAchievement.globalPercent.toFixed(1)}%
</Text>
<TouchableOpacity onPress={() => setSelectedAchievement(null)}>
<Text style={styles.closeBtn}>关闭</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
)}
这里做了什么:
- 点击成就 - 打开一个 Modal 显示详细信息
- 大图标显示 - 在 Modal 中显示更大的成就图标
- 完整描述 - 显示完整的成就描述
- 关闭按钮 - 点击关闭 Modal
Modal 的作用: Modal 是一个模态对话框,会覆盖整个屏幕。用户必须与 Modal 交互(比如点击关闭按钮)才能返回主页面。这样可以确保用户注意到详情信息。
transparent 属性: transparent={true} 使 Modal 的背景透明。这样可以看到后面的内容,但用户无法与后面的内容交互。
selectedAchievement 的作用: 这个状态存储当前选中的成就。当用户点击成就时,设置这个状态。当用户点击关闭按钮时,设置为 null,Modal 就会隐藏。
条件渲染: {selectedAchievement && (...)} 只有当 selectedAchievement 不为 null 时,才显示 Modal。这样可以避免不必要的渲染。
样式设计
成就项的样式
achievementItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
marginHorizontal: 16,
marginVertical: 6,
backgroundColor: '#1b2838',
borderRadius: 8,
},
achievementIcon: {
width: 48,
height: 48,
borderRadius: 4,
marginRight: 12,
},
achievementInfo: {
flex: 1,
},
achievementTitle: {
fontSize: 14,
fontWeight: '600',
color: '#acdbf5',
marginBottom: 4,
},
achievementDesc: {
fontSize: 12,
color: '#8f98a0',
lineHeight: 16,
},
achievementPercent: {
alignItems: 'center',
},
percentText: {
fontSize: 12,
color: '#66c0f4',
fontWeight: '600',
},
这里的设计考量:
- flexDirection: ‘row’ - 图标、信息、百分比并排显示
- flex: 1 - 信息区占据剩余空间
- borderRadius: 8 - 成就项有圆角,看起来更现代
- marginVertical: 6 - 成就项之间有适当的间距
布局的原理: 使用 flexbox 布局,图标固定宽度(48),信息区占据剩余空间(flex: 1),百分比靠右对齐。这样可以充分利用屏幕空间。
圆角的作用: borderRadius: 8 使成就项的四个角都有圆角。这是现代 UI 的常见做法,看起来更柔和。
间距的设计: marginVertical: 6 表示上下间距各 6。这样成就项之间有适当的间距,不会显得拥挤。
图标的圆角: 图标也有 borderRadius: 4,但比成就项的圆角小。这样图标看起来更精致。
统计信息的样式
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: 16,
backgroundColor: '#1b2838',
borderBottomWidth: 1,
borderBottomColor: '#2a475e',
},
statItem: {
alignItems: 'center',
},
statLabel: {
fontSize: 12,
color: '#8f98a0',
marginBottom: 4,
},
statValue: {
fontSize: 18,
fontWeight: 'bold',
color: '#66c0f4',
},
这里的设计考量:
- 三等分布局 - 三个统计项均匀分布
- 大字体 - 统计数值用较大的字体,便于快速识别
- 蓝色强调 - 统计数值用强调色,吸引用户注意
justifyContent: ‘space-around’ 的作用: 这个属性使三个统计项均匀分布在容器中,每个项之间的间距相等。这样看起来很整齐。
字体大小的层级: 标签用 12px,数值用 18px。这样用户能快速看到数值,标签是辅助信息。
颜色的对比: 标签用灰色(#8f98a0),数值用蓝色(#66c0f4)。这样数值更突出,用户能快速扫描。
边框的作用: borderBottomWidth: 1 在统计信息下方添加一条分割线。这样可以清晰地区分统计信息和成就列表。
性能优化
大列表的优化
如果游戏有很多成就(比如 100+),列表会很长。可以使用虚拟化列表:
<FlatList
data={sortedAchievements}
initialNumToRender={10}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
// ... 其他属性
/>
这里做了什么:
- initialNumToRender - 初始渲染 10 条
- maxToRenderPerBatch - 每批渲染 10 条
- updateCellsBatchingPeriod - 50ms 更新一批
这样可以显著提高列表的滚动性能。
虚拟化的原理: FlatList 会根据这些参数计算应该渲染哪些项。初始时只渲染 10 条,当用户滚动时,每 50ms 渲染一批新的项。这样可以避免一次性渲染所有项。
性能的提升: 如果有 1000 条成就,一次性渲染会创建 1000 个 React 组件,占用大量内存。使用虚拟化后,内存中只有可见的项和缓冲区的项,大大减少了内存占用。
滚动的流畅性: 虚拟化可以让滚动更流畅。因为不需要渲染所有项,所以帧率会更高。
参数的调整: 这些参数可以根据实际情况调整。如果列表项很复杂,可以减少 maxToRenderPerBatch。如果需要更快的初始加载,可以减少 initialNumToRender。
图片缓存
<Image
source={{uri: item.icon}}
style={styles.achievementIcon}
cache="force-cache"
/>
这里做了什么: cache="force-cache" 告诉 React Native 优先使用缓存的图片。
缓存的好处: 第一次加载图片时,React Native 会从网络下载,然后保存到本地缓存。下次加载同一个图片时,就直接从缓存读取,不需要再下载。这样可以显著提高加载速度。
缓存策略的选择: force-cache 表示优先使用缓存。还有其他策略,比如 reload(总是从网络下载)、default(默认行为)等。对于成就图标这样的静态资源,force-cache 是最合适的。
缓存的管理: React Native 会自动管理图片缓存,当缓存超过一定大小时,会自动清理最旧的图片。
完整页面示例
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 {getGameSchema, getGlobalAchievements} from '../api/steam';
export const GameAchievementsScreen = () => {
const {selectedAppId} = useApp();
const [achievements, setAchievements] = useState<any[]>([]);
const [stats, setStats] = useState({total: 0, unlocked: 0});
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'difficulty' | 'name'>('difficulty');
useEffect(() => {
if (!selectedAppId) return;
Promise.all([
getGameSchema(selectedAppId),
getGlobalAchievements(selectedAppId),
]).then(([schema, percentages]) => {
const merged = mergeAchievements(schema, percentages);
setAchievements(merged);
setStats({
total: merged.length,
unlocked: merged.filter((a: any) => a.percentUnlocked > 0).length,
});
setLoading(false);
}).catch(() => setLoading(false));
}, [selectedAppId]);
const mergeAchievements = (schema: any, percentages: any) => {
const percentMap = new Map();
percentages?.achievementpercentages?.achievements?.forEach((item: any) => {
percentMap.set(item.name, item.percent);
});
return schema?.game?.achievements?.map((achievement: any) => ({
...achievement,
globalPercent: percentMap.get(achievement.name) || 0,
})) || [];
};
const sortedAchievements = [...achievements].sort((a, b) => {
if (sortBy === 'difficulty') {
return a.globalPercent - b.globalPercent;
} else {
return a.displayName.localeCompare(b.displayName, 'zh-CN');
}
});
if (loading) {
return (
<View style={styles.container}>
<Header title="游戏成就" showBack />
<Loading />
<TabBar />
</View>
);
}
return (
<View style={styles.container}>
<Header title="游戏成就" showBack />
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statLabel}>总成就</Text>
<Text style={styles.statValue}>{stats.total}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>已解锁</Text>
<Text style={styles.statValue}>{stats.unlocked}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>完成度</Text>
<Text style={styles.statValue}>
{stats.total > 0 ? Math.round((stats.unlocked / stats.total) * 100) : 0}%
</Text>
</View>
</View>
<View style={styles.sortContainer}>
<TouchableOpacity
style={[styles.sortBtn, sortBy === 'difficulty' && styles.sortBtnActive]}
onPress={() => setSortBy('difficulty')}
>
<Text style={styles.sortBtnText}>按难度</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.sortBtn, sortBy === 'name' && styles.sortBtnActive]}
onPress={() => setSortBy('name')}
>
<Text style={styles.sortBtnText}>按名称</Text>
</TouchableOpacity>
</View>
<FlatList
data={sortedAchievements}
keyExtractor={(item) => item.name}
renderItem={({item}) => (
<View style={styles.achievementItem}>
<Image
source={{uri: item.percentUnlocked > 0 ? item.icon : item.icongray}}
style={styles.achievementIcon}
cache="force-cache"
/>
<View style={styles.achievementInfo}>
{item.hidden ? (
<>
<Text style={styles.achievementTitle}>隐藏成就</Text>
<Text style={styles.achievementDesc}>解锁后显示</Text>
</>
) : (
<>
<Text style={styles.achievementTitle}>{item.displayName}</Text>
<Text style={styles.achievementDesc}>{item.description}</Text>
</>
)}
</View>
<View style={styles.achievementPercent}>
<Text style={styles.percentText}>{item.globalPercent.toFixed(1)}%</Text>
</View>
</View>
)}
/>
<TabBar />
</View>
);
};
const styles = StyleSheet.create({
container: {flex: 1, backgroundColor: '#171a21'},
statsContainer: {flexDirection: 'row', justifyContent: 'space-around', padding: 16, backgroundColor: '#1b2838'},
statItem: {alignItems: 'center'},
statLabel: {fontSize: 12, color: '#8f98a0', marginBottom: 4},
statValue: {fontSize: 18, fontWeight: 'bold', color: '#66c0f4'},
sortContainer: {flexDirection: 'row', padding: 12, backgroundColor: '#1b2838'},
sortBtn: {flex: 1, paddingVertical: 8, marginHorizontal: 4, borderRadius: 4, backgroundColor: '#2a475e', alignItems: 'center'},
sortBtnActive: {backgroundColor: '#66c0f4'},
sortBtnText: {fontSize: 12, color: '#fff', fontWeight: '600'},
achievementItem: {flexDirection: 'row', alignItems: 'center', padding: 12, marginHorizontal: 16, marginVertical: 6, backgroundColor: '#1b2838', borderRadius: 8},
achievementIcon: {width: 48, height: 48, borderRadius: 4, marginRight: 12},
achievementInfo: {flex: 1},
achievementTitle: {fontSize: 14, fontWeight: '600', color: '#acdbf5', marginBottom: 4},
achievementDesc: {fontSize: 12, color: '#8f98a0', lineHeight: 16},
achievementPercent: {alignItems: 'center'},
percentText: {fontSize: 12, color: '#66c0f4', fontWeight: '600'},
});
这里做了什么: 完整的游戏成就页面实现,包括:
- 两个 API 的数据聚合
- 成就统计信息
- 排序功能
- 隐藏成就处理
- 全球达成率显示
小结
游戏成就页面虽然功能相对复杂,但核心思想很清晰:
- 数据聚合 - 将两个 API 的数据合并成统一格式
- 排序和过滤 - 提供多种查看方式
- 隐藏成就处理 - 特殊处理隐藏成就
- 难度可视化 - 用达成率表示成就难度
- 性能优化 - 处理大列表的性能问题
下一篇我们来实现游戏截图页面,这个页面会展示游戏的所有截图,涉及图片网格布局和图片预览功能。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)