rn_for_openharmony_steam资讯app实战-游戏新闻实现
本文介绍了使用React Native开发OpenHarmony版Steam资讯App中游戏新闻页面的实现过程。主要技术点包括: 数据获取:通过Steam的GetNewsForApp API获取游戏新闻数据,解析返回的JSON格式新闻列表 核心功能实现: 使用useEffect处理数据加载 时间格式化函数将Unix时间戳转换为相对时间 FlatList渲染新闻列表,包含标题、时间、作者和内容摘要
React Native for OpenHarmony 实战:Steam 资讯 App 游戏新闻页面
案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam
游戏新闻页面是详情页的快捷入口之一。用户点击"新闻"按钮后,会看到与该游戏相关的最新资讯。这个页面相对简单,但涉及列表渲染、时间格式化、网络请求等常见的开发模式。
新闻 API 的特点
Steam 提供的新闻 API 是 GetNewsForApp,这个接口返回的数据结构相对简单。
export const getAppNews = async (appId: number, count = 10) => {
const res = await fetch(
`${COMMUNITY_API}/ISteamNews/GetNewsForApp/v2/?appid=${appId}&count=${count}`,
);
return res.json();
};
这里做了什么: 定义了一个获取游戏新闻的函数。appId 是游戏的 ID,count 是要获取的新闻数量(默认 10 条)。通过 URL 参数传递这两个值,API 会返回 JSON 格式的新闻列表。
API 返回的数据结构大概是这样的:
{
"appnews": {
"appid": 730,
"newsitems": [
{
"gid": "123456789",
"title": "新闻标题",
"url": "https://steamcommunity.com/gid/...",
"is_external_url": false,
"author": "Valve",
"contents": "新闻内容摘要...",
"feedlabel": "Community Announcements",
"date": 1704067200,
"feedname": "steam_community_announcements"
}
]
}
}
关键字段说明:
title- 新闻标题,直接显示在列表中contents- 新闻内容,可以显示摘要或完整内容date- Unix 时间戳,需要转换成可读的日期格式author- 新闻作者,通常是"Valve"或社区用户名feedlabel- 新闻分类标签,比如"Community Announcements"
页面结构设计
新闻页面的布局很直接:
顶部 是 Header,显示游戏名称和返回按钮。
中间 是新闻列表,每条新闻显示标题、作者、发布时间和摘要。
底部 是 TabBar,保持应用的导航一致性。
用户点击某条新闻,可以跳转到新闻详情页(这个功能可以在后续实现)。
核心代码实现
组件初始化
export const GameNewsScreen = () => {
const {selectedAppId, navigate} = useApp();
const [news, setNews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
这里做了什么: 从 AppContext 获取当前选中的游戏 ID 和导航函数。定义了两个状态:news 存储新闻列表,loading 控制加载状态。
为什么这样设计: 使用 AppContext 而不是通过 props 传递数据,是因为这样可以避免 props drilling(属性钻取)。当组件层级很深时,通过 props 一层层传递数据会很麻烦。AppContext 提供了一个全局的状态管理方案,任何组件都可以直接访问。
状态的作用: news 数组存储从 API 获取的新闻列表,每次更新时会触发组件重新渲染。loading 状态用来控制是否显示加载动画,当数据还在加载时显示 Loading 组件,加载完成后显示列表。
数据加载
useEffect(() => {
if (!selectedAppId) return;
getAppNews(selectedAppId, 20).then(data => {
setNews(data?.appnews?.newsitems || []);
setLoading(false);
}).catch(() => setLoading(false));
}, [selectedAppId]);
这里做了什么: 当组件挂载或 selectedAppId 变化时,调用 getAppNews() 获取新闻。这里请求 20 条新闻(比默认的 10 条多)。然后提取 appnews.newsitems 数组,如果为空则使用空数组。
依赖数组的作用: [selectedAppId] 表示只有当 selectedAppId 变化时才会重新执行这个 effect。如果用户从一个游戏切换到另一个游戏,selectedAppId 会改变,这时会自动重新加载新的游戏的新闻。
错误处理: 使用 .catch() 捕获网络错误或 API 错误。即使请求失败,也会把 loading 设为 false,这样用户不会一直看到加载动画。在实际项目中,可以在这里添加错误提示。
可选链操作符: data?.appnews?.newsitems 使用了可选链操作符,这样即使 data 或 appnews 不存在,也不会抛出错误,而是返回 undefined。然后用 || [] 提供一个默认的空数组。
时间格式化函数
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
if (diff < 604800) return `${Math.floor(diff / 86400)}天前`;
return date.toLocaleDateString('zh-CN');
};
这里做了什么: 将 Unix 时间戳转换成相对时间(比如"2小时前")或绝对日期。这样的显示方式更符合用户习惯。
- 刚刚 - 不到 1 分钟
- X分钟前 - 不到 1 小时
- X小时前 - 不到 1 天
- X天前 - 不到 1 周
- 具体日期 - 超过 1 周
时间戳转换: Steam API 返回的是 Unix 时间戳(秒),但 JavaScript 的 Date 对象需要毫秒,所以要乘以 1000。
时间差计算: 计算当前时间和新闻发布时间的差值(秒),然后根据这个差值判断显示哪种格式。这样用户能快速了解新闻的新旧程度。
国际化支持: toLocaleDateString('zh-CN') 使用中文格式显示日期,比如"2024/1/1"。如果需要支持其他语言,可以根据用户的语言设置动态改变这个参数。
新闻列表项的渲染
<FlatList
data={news}
keyExtractor={(item) => item.gid}
renderItem={({item}) => (
<TouchableOpacity style={styles.newsItem} onPress={() => {
// 点击新闻的处理逻辑
}}>
<View style={styles.newsHeader}>
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.newsDate}>{formatDate(item.date)}</Text>
</View>
<Text style={styles.newsAuthor}>by {item.author}</Text>
<Text style={styles.newsContent} numberOfLines={3}>{item.contents}</Text>
</TouchableOpacity>
)}
/>
这里做了什么: 使用 FlatList 渲染新闻列表。每条新闻显示:
- 标题 - 用
numberOfLines={2}限制最多显示 2 行,超出部分用省略号表示 - 发布时间 - 用格式化后的相对时间
- 作者 - 显示谁发布的这条新闻
- 摘要 - 用
numberOfLines={3}限制最多显示 3 行
keyExtractor 用 gid(新闻的唯一 ID)作为 key,这样 React 能正确追踪每条新闻。
为什么用 FlatList: FlatList 是 React Native 中用于渲染长列表的最佳选择。它会自动进行虚拟化,只渲染可见的项,不可见的项不会被渲染到内存中。这样即使有 1000 条新闻,应用也不会卡顿。
numberOfLines 的作用: 这个属性限制文本显示的行数。如果文本超过指定行数,会自动添加省略号(…)。这样可以保证列表的整齐,不会因为某条新闻特别长而破坏布局。
TouchableOpacity 的作用: 这是一个可点击的容器,用户点击时会有透明度变化的反馈。这样用户能感受到点击的反应,提升交互体验。
加载和空状态
if (loading) {
return (
<View style={styles.container}>
<Header title={`${gameName} - 新闻`} showBack />
<Loading />
<TabBar />
</View>
);
}
if (news.length === 0) {
return (
<View style={styles.container}>
<Header title={`${gameName} - 新闻`} showBack />
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>暂无新闻</Text>
</View>
<TabBar />
</View>
);
}
这里做了什么:
- 加载状态 - 显示 Loading 组件,告诉用户正在加载
- 空状态 - 如果没有新闻,显示"暂无新闻"提示,而不是一个空白页面
这两个状态的处理能显著提升用户体验。
为什么要处理这些状态: 如果不处理加载状态,用户会看到一个空白页面,不知道发生了什么。如果不处理空状态,用户会看到一个空白列表,也不知道是没有新闻还是加载失败。通过显示明确的提示,用户能理解当前的情况。
Loading 组件的作用: 这是一个自定义的加载动画组件,通常显示一个旋转的图标或进度条。这样用户知道应用正在工作,而不是卡住了。
提前返回的模式: 这种模式叫做"提前返回"(early return),它可以让代码更清晰。如果条件不满足,就直接返回,不需要嵌套多层 if-else。
样式设计
新闻项的样式
newsItem: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#2a475e',
backgroundColor: '#1b2838',
},
newsHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 8,
},
newsTitle: {
fontSize: 14,
fontWeight: '600',
color: '#acdbf5',
flex: 1,
marginRight: 8,
},
newsDate: {
fontSize: 12,
color: '#8f98a0',
marginTop: 2,
},
这里的设计考量:
- padding 16 - 给每条新闻足够的内边距,不会显得拥挤
- 边框分割 - 用 1px 的边框分割每条新闻,而不是用空白
- flexDirection: ‘row’ - 标题和时间并排显示,节省空间
- flex: 1 - 标题占据剩余空间,时间靠右对齐
- numberOfLines - 限制文本行数,防止某条新闻过长
padding 的作用: padding 是内边距,它决定了内容和容器边界之间的距离。16 是一个常见的间距单位,既不会太拥挤,也不会太空旷。
边框 vs 空白: 使用边框分割而不是空白的好处是节省空间。如果用空白分割,每条新闻之间会有很大的间隙,列表会显得很长。用边框分割可以让列表更紧凑。
flex 布局: flexDirection: 'row' 表示子元素水平排列。flex: 1 表示该元素占据剩余的所有空间。这样标题会自动扩展,时间会靠右对齐。
marginTop: 2: 这是一个微调,让时间和标题的顶部对齐。如果不加这个,时间会因为字体大小不同而显得不对齐。
文本颜色的层级
newsTitle: {color: '#acdbf5'}, // 主文本 - 浅蓝
newsAuthor: {color: '#8f98a0'}, // 辅助文本 - 灰色
newsContent: {color: '#8f98a0'}, // 摘要文本 - 灰色
newsDate: {color: '#8f98a0'}, // 时间 - 灰色
这里的设计考量: 标题用主文本色,其他信息用辅助文本色,这样能清晰地区分信息的重要程度。
颜色的心理学: 浅蓝色(#acdbf5)是 Steam 的品牌色,用来吸引用户的注意力。灰色(#8f98a0)是中性色,用来显示次要信息。这样的配色方案能引导用户的视线,让他们先看到标题,再看到其他信息。
对比度: 浅蓝色和深色背景(#1b2838)的对比度很高,容易阅读。灰色的对比度稍低,但仍然可以阅读。这样的设计能帮助用户快速扫描列表。
一致性: 整个应用都使用这套颜色方案,这样用户能快速适应,知道哪些是重要信息,哪些是次要信息。
处理不同的新闻类型
Steam 的新闻有不同的类型,比如官方公告、社区新闻、补丁说明等。
const getNewsLabel = (feedlabel: string) => {
const labels: Record<string, string> = {
'Community Announcements': '📢 官方公告',
'Updates': '🔄 更新',
'News': '📰 新闻',
'Patch Notes': '🔧 补丁',
};
return labels[feedlabel] || feedlabel;
};
这里做了什么: 根据 feedlabel 字段,显示不同的标签和 emoji。这样用户能快速识别新闻的类型。
为什么要分类: 不同类型的新闻对用户的重要性不同。官方公告可能很重要,需要用户立即了解。而普通新闻可能只是信息分享。通过分类和 emoji,用户能快速判断新闻的重要性。
emoji 的作用: emoji 是一种通用的视觉语言,不需要翻译就能理解。📢 表示公告,🔄 表示更新,📰 表示新闻,🔧 表示补丁。这样用户能一眼看出新闻的类型。
Record 类型: Record<string, string> 是 TypeScript 的类型定义,表示一个对象,键和值都是字符串。这样可以提供类型安全,避免拼写错误。
可以在新闻项中添加这个标签:
<View style={styles.newsFooter}>
<Text style={styles.newsLabel}>{getNewsLabel(item.feedlabel)}</Text>
</View>
这里做了什么: 在新闻项的底部显示新闻类型标签。这样用户能快速了解新闻的类型。
新闻内容的处理
Steam 返回的新闻内容有时候包含 HTML 标签,比如 <br> 或 <p>。如果直接显示,会看到这些标签。
const cleanContent = (content: string) => {
return content
.replace(/<[^>]*>/g, '') // 移除 HTML 标签
.replace(/ /g, ' ') // 替换 HTML 空格
.replace(/</g, '<') // 替换 HTML 转义字符
.replace(/>/g, '>')
.replace(/&/g, '&')
.trim();
};
这里做了什么: 使用正则表达式清理 HTML 标签和转义字符。这样显示的内容就是纯文本,更容易阅读。
正则表达式的解释:
/<[^>]*>/g- 匹配所有 HTML 标签(<和>之间的内容)/ /g- 匹配 HTML 空格实体/</g- 匹配 HTML 转义的</>/g- 匹配 HTML 转义的>/&/g- 匹配 HTML 转义的&
为什么要清理: Steam API 返回的新闻内容是从网页上爬取的,可能包含 HTML 标签。如果直接显示,用户会看到 <p>这是一段文字</p> 这样的内容,很不美观。清理后就是纯文本,更容易阅读。
trim() 的作用: 移除字符串开头和结尾的空白字符。有时候清理后的内容可能有多余的空白,trim() 可以清理掉。
在渲染时使用:
<Text style={styles.newsContent} numberOfLines={3}>
{cleanContent(item.contents)}
</Text>
这里做了什么: 在显示新闻摘要时,先调用 cleanContent() 清理 HTML 标签,然后显示。这样用户看到的是干净的文本。
下拉刷新的实现
用户可能想手动刷新新闻列表,可以添加下拉刷新功能:
const [refreshing, setRefreshing] = useState(false);
const onRefresh = () => {
setRefreshing(true);
getAppNews(selectedAppId, 20).then(data => {
setNews(data?.appnews?.newsitems || []);
setRefreshing(false);
}).catch(() => setRefreshing(false));
};
<FlatList
data={news}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
// ... 其他属性
/>
这里做了什么:
- refreshing 状态 - 控制刷新动画的显示
- onRefresh 函数 - 用户下拉时调用,重新获取新闻
- RefreshControl - React Native 内置的下拉刷新组件
用户下拉列表时,会看到一个旋转的刷新图标,松开后会重新加载新闻。
refreshing 状态的作用: 这个状态控制刷新动画是否显示。当用户下拉时,refreshing 为 true,显示旋转的刷新图标。当数据加载完成后,设为 false,隐藏刷新图标。
onRefresh 函数的逻辑: 这个函数会在用户下拉时被调用。它重新获取新闻,然后更新 news 状态。这样用户就能看到最新的新闻。
错误处理: 即使请求失败,也会把 refreshing 设为 false,这样用户不会一直看到刷新动画。
用户体验: 下拉刷新是一个常见的交互模式,用户已经很熟悉。通过这个功能,用户能轻松地获取最新的新闻,而不需要关闭应用重新打开。
分页加载的考虑
如果新闻很多,一次加载 20 条可能还不够。可以实现分页加载:
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadMore = () => {
if (!hasMore) return;
const newCount = page * 20;
getAppNews(selectedAppId, newCount).then(data => {
const items = data?.appnews?.newsitems || [];
setNews(items);
setPage(page + 1);
setHasMore(items.length >= newCount);
});
};
<FlatList
onEndReached={loadMore}
onEndReachedThreshold={0.5}
// ... 其他属性
/>
这里做了什么:
- onEndReached - 当用户滚动到列表底部时触发
- onEndReachedThreshold={0.5} - 距离底部还有 50% 的距离时就触发
- hasMore - 判断是否还有更多新闻可以加载
这样用户滚动到底部时,会自动加载更多新闻。
分页的原理: 每次加载时,我们请求 page * 20 条新闻。第一次加载 20 条,第二次加载 40 条(包括之前的 20 条),第三次加载 60 条。这样可以获取更多的新闻。
hasMore 的作用: 这个状态用来判断是否还有更多新闻。如果返回的新闻数量小于请求的数量,说明已经到底了,不需要再加载。
onEndReachedThreshold 的作用: 这个属性控制触发 onEndReached 的距离。0.5 表示距离底部还有 50% 的距离时就触发。这样用户不需要滚动到最底部才能加载更多,提升了用户体验。
性能考虑: 这种分页加载的方式可以减少初始加载时间,用户能更快地看到新闻。同时,也避免了一次性加载太多数据导致的内存问题。
新闻详情页的预留
点击新闻后,可以跳转到新闻详情页查看完整内容:
<TouchableOpacity
style={styles.newsItem}
onPress={() => {
setSelectedNews(item);
navigate('newsDetail');
}}
>
{/* 新闻内容 */}
</TouchableOpacity>
这里做了什么: 保存当前点击的新闻到 AppContext,然后导航到新闻详情页。详情页可以显示完整的新闻内容,甚至可以打开原始链接。
为什么要保存到 AppContext: 新闻详情页需要知道用户点击的是哪条新闻。通过 AppContext,我们可以在全局状态中保存这个信息,这样详情页就能访问到。
导航的流程: 用户点击新闻 → 保存新闻信息 → 导航到详情页 → 详情页从 AppContext 获取新闻信息 → 显示完整内容。
后续实现: 新闻详情页可以显示完整的新闻内容、作者、发布时间等。还可以添加一个按钮,让用户在浏览器中打开原始链接。
性能优化
列表性能
使用 FlatList 而不是 ScrollView + map() 的原因是:
- 虚拟化 - FlatList 只渲染可见的项,不可见的项不会渲染
- 内存效率 - 即使有 1000 条新闻,也只占用少量内存
- 滚动流畅 - 不会因为渲染大量项而卡顿
虚拟化的原理: FlatList 会计算当前可见的区域,只渲染这个区域内的项。当用户滚动时,不可见的项会被卸载,新的可见项会被渲染。这样可以大大减少内存占用。
对比 ScrollView: 如果用 ScrollView + map(),所有的项都会被渲染到内存中,即使用户看不到。这样如果有 1000 条新闻,就会有 1000 个 React 组件在内存中,非常浪费。
性能指标: 使用 FlatList 可以让列表的滚动帧率保持在 60fps,用户体验会很流畅。
图片加载
如果新闻中包含图片,可以添加图片缓存:
<Image
source={{uri: item.image}}
style={styles.newsImage}
cache="force-cache"
/>
这里做了什么: cache="force-cache" 告诉 React Native 优先使用缓存的图片,减少网络请求。
缓存的作用: 第一次加载图片时,React Native 会从网络下载,然后保存到本地缓存。下次加载同一个图片时,就直接从缓存读取,不需要再下载。这样可以显著提高加载速度。
缓存策略: force-cache 表示优先使用缓存。如果缓存中没有,才从网络下载。还有其他策略,比如 reload(总是从网络下载)、default(默认行为)等。
内存管理: React Native 会自动管理图片缓存,当缓存超过一定大小时,会自动清理最旧的图片。
完整页面示例
import React, {useEffect, useState} from 'react';
import {View, Text, FlatList, TouchableOpacity, StyleSheet, RefreshControl} from 'react-native';
import {Header} from '../components/Header';
import {TabBar} from '../components/TabBar';
import {Loading} from '../components/Loading';
import {useApp} from '../store/AppContext';
import {getAppNews} from '../api/steam';
export const GameNewsScreen = () => {
const {selectedAppId, navigate} = useApp();
const [news, setNews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
if (!selectedAppId) return;
getAppNews(selectedAppId, 20).then(data => {
setNews(data?.appnews?.newsitems || []);
setLoading(false);
}).catch(() => setLoading(false));
}, [selectedAppId]);
const formatDate = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const now = new Date();
const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
if (diff < 604800) return `${Math.floor(diff / 86400)}天前`;
return date.toLocaleDateString('zh-CN');
};
const onRefresh = () => {
setRefreshing(true);
getAppNews(selectedAppId, 20).then(data => {
setNews(data?.appnews?.newsitems || []);
setRefreshing(false);
}).catch(() => setRefreshing(false));
};
if (loading) {
return (
<View style={styles.container}>
<Header title="游戏新闻" showBack />
<Loading />
<TabBar />
</View>
);
}
return (
<View style={styles.container}>
<Header title="游戏新闻" showBack />
<FlatList
data={news}
keyExtractor={(item) => item.gid}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
renderItem={({item}) => (
<TouchableOpacity style={styles.newsItem}>
<View style={styles.newsHeader}>
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.newsDate}>{formatDate(item.date)}</Text>
</View>
<Text style={styles.newsAuthor}>by {item.author}</Text>
<Text style={styles.newsContent} numberOfLines={3}>{item.contents}</Text>
</TouchableOpacity>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>暂无新闻</Text>
</View>
}
/>
<TabBar />
</View>
);
};
const styles = StyleSheet.create({
container: {flex: 1, backgroundColor: '#171a21'},
newsItem: {padding: 16, borderBottomWidth: 1, borderBottomColor: '#2a475e'},
newsHeader: {flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8},
newsTitle: {fontSize: 14, fontWeight: '600', color: '#acdbf5', flex: 1},
newsDate: {fontSize: 12, color: '#8f98a0'},
newsAuthor: {fontSize: 12, color: '#8f98a0', marginBottom: 6},
newsContent: {fontSize: 13, color: '#8f98a0', lineHeight: 18},
emptyContainer: {flex: 1, justifyContent: 'center', alignItems: 'center'},
emptyText: {fontSize: 16, color: '#8f98a0'},
});
这里做了什么: 完整的游戏新闻页面实现,包括:
- 新闻列表的加载和渲染
- 时间格式化
- 下拉刷新
- 空状态处理
- 样式定义
小结
游戏新闻页面虽然功能相对简单,但涉及了很多实用的开发技巧:
- 时间格式化 - 将 Unix 时间戳转换成用户友好的格式
- 列表优化 - 使用 FlatList 提高性能
- 下拉刷新 - 提升用户体验
- 空状态处理 - 让应用更完善
- 内容清理 - 处理 HTML 标签和转义字符
下一篇我们来实现游戏成就页面,这个页面会展示游戏的所有成就和达成率,涉及更复杂的数据处理。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)