React Native for OpenHarmony 实战:Steam 资讯 App 游戏标签页面

案例开源地址:https://atomgit.com/nutpi/rn_openharmony_steam

标签是 Steam 上另一种重要的游戏分类方式。与游戏类型(RPG、射击等)不同,标签是更细致的特征标记(单人、多人、支持手柄等)。由于标签数量众多且不断变化,我们需要从游戏详情中动态提取标签,而不是手动维护。
请添加图片描述

标签数据的动态提取

Steam 没有直接的标签查询 API,但每个游戏的详情中都包含 tags 字段。我们的策略是:

  1. 维护一个热门游戏列表
  2. 获取这些游戏的详情
  3. 从详情中提取标签并统计频率
  4. 显示最热门的标签

首先定义热门游戏列表:

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,完成后设为 false
  • searchQuery - 存储用户在搜索框中输入的内容,用于实时过滤标签
  • 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 个标签,避免列表过长。这个数字可以根据需要调整
  • 更新状态 - 将处理后的标签列表存储到 tagsfilteredTags

这个实现的关键是用对象来统计标签频率,然后转换成数组进行排序。这样可以高效地处理大量标签数据。

这样的实现让标签数据是动态的,不需要手动维护。

标签搜索的实现

用户可以搜索标签,快速找到想要的标签:

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

Logo

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

更多推荐