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 使用了可选链操作符,这样即使 dataappnews 不存在,也不会抛出错误,而是返回 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 行

keyExtractorgid(新闻的唯一 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(/&nbsp;/g, ' ')  // 替换 HTML 空格
    .replace(/&lt;/g, '<')    // 替换 HTML 转义字符
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&')
    .trim();
};

这里做了什么: 使用正则表达式清理 HTML 标签和转义字符。这样显示的内容就是纯文本,更容易阅读。

正则表达式的解释:

  • /<[^>]*>/g - 匹配所有 HTML 标签(<> 之间的内容)
  • /&nbsp;/g - 匹配 HTML 空格实体
  • /&lt;/g - 匹配 HTML 转义的 <
  • /&gt;/g - 匹配 HTML 转义的 >
  • /&amp;/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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐