案例开源地址:在这里插入图片描述
https://atomgit.com/nutpi/Rn_openharmony_AnimeHub

最近在做一个动漫信息浏览应用 AnimeHub,使用 React Native for OpenHarmony 开发。今天分享一下首页的实现过程,这个页面承载了整个应用的核心入口功能。

首页要做什么

作为一个动漫应用,首页需要让用户快速找到想看的内容。我规划了这几个模块:顶部欢迎区域和快捷操作按钮、四个快捷入口(热门、分类、季度、制作公司)、多个横向滚动的动漫列表,以及下拉刷新功能。

首页是用户打开应用后看到的第一个界面,所以信息密度要适中。太少了显得空洞,太多了又会让人眼花缭乱。我的思路是用横向滚动列表来展示不同类型的动漫,每个列表只显示 10 个,想看更多就点进去。这样既能展示足够多的内容,又不会让页面太长。

主题配置

在写页面之前,先把主题色定好。我选择了深色系配色,主色调是蓝紫色,看起来比较有科技感,也符合动漫应用的调性。

export const Colors = {
  primary: '#6366F1',
  primaryDark: '#4F46E5',
  background: '#0F0F1A',
  backgroundLight: '#1A1A2E',
  text: '#FFFFFF',
  textSecondary: '#A0A0B0',
};

代码说明:

  • primary - 主色调蓝紫色,用于按钮、链接等强调元素
  • primaryDark - 深一点的版本,可以用在按下状态或者需要更强对比的地方
  • background - 深色背景,给整个应用一种沉浸感,看动漫信息的时候不会觉得刺眼
  • backgroundLight - 稍微亮一点,用于卡片或者需要区分层次的地方
  • texttextSecondary - 分别是主要文字和次要文字的颜色,形成层次对比

把这些值抽出来统一管理,后面改起来方便,整个应用的视觉风格也能保持一致。如果哪天想换个主题色,改一个地方就行了。

间距和圆角也做了统一定义:

export const Spacing = {
  xs: 4,
  sm: 8,
  md: 12,
  lg: 16,
  xl: 20,
  xxl: 24,
};

设计思路:

这套间距系统从 4px 开始,按照一定比例递增。写样式的时候直接用 Spacing.md 这样的方式引用,比写死数字要好维护得多。而且团队协作的时候,大家用同一套间距系统,界面看起来会更统一。

export const BorderRadius = {
  sm: 6,
  md: 10,
  lg: 14,
  full: 999,
};

各尺寸用途:

  • sm - 用于小元素比如标签
  • md - 用于卡片
  • lg - 用于大按钮或者弹窗
  • full - 设成 999 是为了做圆形按钮,不管元素多大都能保证是圆的

API 限流处理

数据来源是 Jikan API,这是一个免费的 MyAnimeList 非官方 API,数据很全,而且不需要注册就能用。不过它有请求频率限制,每秒最多 3 个请求,超了就返回 429 错误。

一开始我用 Promise.all 并行请求四个接口,结果经常触发限流,页面一片空白。后来研究了一下,发现必须把请求串行化,一个一个发。

let lastRequestTime = 0;
const MIN_INTERVAL = 400;
let requestQueue: Promise<any> = Promise.resolve();

变量说明:

  • lastRequestTime - 记录上次请求的时间戳,用来计算距离上次请求过了多久
  • MIN_INTERVAL - 两次请求之间的最小间隔,设成 400ms 留点余量,比 333ms(1秒/3次)多一点更保险
  • requestQueue - 一个 Promise 链,所有请求都会挂到这个链上串行执行,这是实现串行化的关键
const rateLimitedFetch = async (url: string): Promise<any> => {
  return new Promise((resolve, reject) => {
    requestQueue = requestQueue.then(async () => {
      const now = Date.now();
      const timeSinceLastRequest = now - lastRequestTime;
      
      if (timeSinceLastRequest < MIN_INTERVAL) {
        await new Promise(r => setTimeout(r, MIN_INTERVAL - timeSinceLastRequest));
      }
      lastRequestTime = Date.now();
      // ... 发起请求
    });
  });
};

核心逻辑:

每个请求进来后,先算一下距离上次请求过了多久。如果不够 400ms,就用 setTimeout 等一会儿再发。通过 Promise 链的方式,保证所有请求都是一个接一个执行的,不会并发。

具体来说,每次调用 rateLimitedFetch,都会把一个新的 Promise 挂到 requestQueue 后面。这样即使同时调用四次,它们也会排队执行,第一个完成后才会开始第二个。这种模式在处理有频率限制的 API 时非常有用。

如果还是遇到 429 错误,代码里还有重试逻辑:

if (response.status === 429) {
  await new Promise(r => setTimeout(r, 1000));
  lastRequestTime = Date.now();
  const retryResponse = await fetch(url);
  // ...
}

防御性编程:

遇到限流就等 1 秒后再试一次。这种防御性编程在调用第三方 API 时很有必要,网络环境复杂,什么情况都可能遇到。

横向动漫列表组件

首页有好几个横向滚动的动漫列表,本季新番、正在热播、高分佳作、即将上映,样式差不多,就封装成一个通用组件,避免重复代码。

interface HorizontalAnimeListProps {
  title: string;
  data: Anime[];
  onPressItem: (anime: Anime) => void;
  onPressMore?: () => void;
  icon?: string;
}

Props 设计说明:

  • title - 列表标题,比如"本季新番"
  • data - 动漫数据数组,从 API 获取后传进来
  • onPressItem - 点击某个动漫时的回调,一般是跳转到详情页
  • onPressMore - 点击"更多"按钮的回调,跳转到完整列表页(可选)
  • icon - 标题前面的小图标,让标题更生动(可选)

问号表示可选参数,onPressMoreicon 不传也行,组件内部会判断。这样设计的好处是灵活,有些地方可能不需要"更多"按钮,直接不传就行。

export const HorizontalAnimeList: React.FC<HorizontalAnimeListProps> = ({
  title, data, onPressItem, onPressMore, icon,
}) => {
  if (!data || data.length === 0) return null;
  // ...
};

空数据处理:

组件开头有个判断,如果数据为空就直接返回 null,不渲染任何内容。这样调用方不用自己判断数据是否存在,代码更简洁。比如 API 请求失败了,data 可能是空数组,这时候列表就自动隐藏了,不会显示一个空的标题栏。

<View style={styles.header}>
  <View style={styles.titleRow}>
    {icon && <Icon name={icon} size={20} />}
    <Text style={styles.title}>{title}</Text>
  </View>
  {onPressMore && (
    <TouchableOpacity style={styles.moreButton} onPress={onPressMore}>
      <Text style={styles.moreText}>更多</Text>
      <Icon name="forward" size={14} color={Colors.primary} />
    </TouchableOpacity>
  )}
</View>

条件渲染技巧:

  • icon && - 只有传了 icon 才会显示图标
  • onPressMore && - 只有传了回调才显示"更多"按钮
  • 这种模式在 React 里很常见,比 if-else 简洁
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
  {data.map((anime) => (
    <View key={anime.mal_id} style={styles.cardWrapper}>
      <AnimeCard anime={anime} onPress={() => onPressItem(anime)} size="sm" />
    </View>
  ))}
</ScrollView>

横向滚动实现:

  • horizontal - React Native 内置的横向滚动能力,不需要额外的库
  • showsHorizontalScrollIndicator={false} - 隐藏滚动条让界面更干净
  • key={anime.mal_id} - React 要求列表元素必须有唯一的 key,用于优化渲染性能
  • size="sm" - 用小尺寸的卡片,因为横向列表空间有限

首页状态管理

首页需要管理多个数据列表和加载状态,用 useState 来处理:

const [topAnime, setTopAnime] = useState<Anime[]>([]);
const [seasonAnime, setSeasonAnime] = useState<Anime[]>([]);
const [upcomingAnime, setUpcomingAnime] = useState<Anime[]>([]);
const [airingAnime, setAiringAnime] = useState<Anime[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);

状态说明:

  • 四个数组分别存放不同类型的动漫数据:高分佳作、本季新番、即将上映、正在热播
  • 初始值都是空数组,等 API 返回数据后再填充
  • loading - 控制首次加载时的全屏 Loading 显示,初始值是 true
  • refreshing - 控制下拉刷新时的刷新指示器,初始值是 false

两个状态分开管理,因为它们的 UI 表现不一样:首次加载显示全屏 Loading,下拉刷新只显示顶部的小圈圈。

数据加载逻辑

const loadData = async () => {
  try {
    const topRes = await getTopAnime(1);
    setTopAnime(topRes.data?.slice(0, 10) || []);
    
    const seasonRes = await getSeasonNow(1);
    setSeasonAnime(seasonRes.data?.slice(0, 10) || []);
    
    const upcomingRes = await getUpcomingAnime(1);
    setUpcomingAnime(upcomingRes.data?.slice(0, 10) || []);
    
    const airingRes = await getTopAnime(1, 'airing');
    setAiringAnime(airingRes.data?.slice(0, 10) || []);
  } catch (error) {
    console.error('Load data error:', error);
  } finally {
    setLoading(false);
    setRefreshing(false);
  }
};

关键点解析:

  • 串行请求 - 每个请求都是 await 等待完成后再发下一个,配合前面的限流封装,确保不会触发 429
  • 数据截取 - slice(0, 10) 只取前 10 条,首页不需要展示太多
  • 防御性写法 - || [] 保证最差情况下也是个空数组,不会崩溃
  • finally 块 - 不管成功失败都要关掉加载提示,避免出错时用户一直看到 Loading
useEffect(() => {
  loadData();
}, []);

useEffect 说明:

空依赖数组 [] 表示只执行一次。如果不传依赖数组,每次渲染都会执行,那就死循环了。

下拉刷新实现

<ScrollView
  showsVerticalScrollIndicator={false}
  refreshControl={
    <RefreshControl
      refreshing={refreshing}
      onRefresh={handleRefresh}
      tintColor={Colors.primary}
    />
  }
>

RefreshControl 属性:

  • refreshing - 控制刷新指示器的显示,true 就显示转圈动画
  • onRefresh - 用户下拉触发刷新时的回调
  • tintColor - 刷新指示器的颜色,用主题色保持视觉统一
const handleRefresh = () => {
  setRefreshing(true);
  loadData();
};

刷新逻辑:

先把 refreshing 设为 true 显示刷新指示器,然后调用 loadData 重新加载数据。数据加载完成后,finally 块会把 refreshing 设回 false。这里没有用 await,因为不需要等待,loadData 内部是异步的,完成后会自己更新状态。

快捷入口设计

首页顶部有四个快捷入口,让用户能快速跳转到常用功能:

<TouchableOpacity 
  style={styles.quickAction}
  onPress={() => navigation.navigate('TopAnime', { filter: '', title: '热门排行' })}
>
  <View style={[styles.quickIcon, { backgroundColor: '#FF6B6B' }]}>
    <Icon name="fire" size={24} />
  </View>
  <Text style={styles.quickText}>热门</Text>
</TouchableOpacity>

设计要点:

  • 颜色区分 - 红色代表热门、青色代表分类、蓝色代表季度、绿色代表制作公司
  • 颜色含义 - 红色给人热烈的感觉适合"热门",绿色给人稳重的感觉适合"制作公司"
  • 参数传递 - 通过 navigation.navigate 的第二个参数传递 filter 和 title
  • 样式复用 - styles.quickIcon 定义基本样式,{ backgroundColor } 覆盖背景色

顶部标题栏

<View style={styles.header}>
  <View>
    <Text style={styles.greeting}>欢迎来到</Text>
    <Text style={styles.appName}>AnimeHub</Text>
  </View>
  <View style={styles.headerActions}>
    <TouchableOpacity 
      style={styles.iconButton}
      onPress={() => navigation.navigate('RandomAnime')}
    >
      <Icon name="dice" size={22} />
    </TouchableOpacity>
    <TouchableOpacity 
      style={styles.iconButton}
      onPress={() => navigation.navigate('Settings')}
    >
      <Icon name="settings" size={22} />
    </TouchableOpacity>
  </View>
</View>

布局说明:

  • 左侧 - 欢迎语和应用名,形成品牌感
  • 右侧 - 两个功能按钮
    • 骰子图标 - 跳转到随机推荐页,给用户一个"今天看什么"的惊喜
    • 齿轮图标 - 跳转到设置页
  • 按钮样式 - 圆形按钮,用 borderRadius: BorderRadius.full 实现,背景色用 backgroundLight 形成层次感

列表渲染

<HorizontalAnimeList
  title="本季新番"
  icon="sparkle"
  data={seasonAnime}
  onPressItem={navigateToDetail}
  onPressMore={() => navigation.navigate('Seasonal')}
/>

组件复用:

有了封装好的 HorizontalAnimeList 组件,渲染列表就很简洁了。四个列表的代码结构完全一样,只是数据和跳转目标不同。这就是组件封装的好处,避免重复代码,改起来也方便。

const navigateToDetail = (anime: Anime) => {
  navigation.navigate('AnimeDetail', { animeId: anime.mal_id });
};

详情跳转:

把动漫的 ID 传给详情页,详情页再根据 ID 去请求完整信息。这样首页只需要基本信息,加载更快。

底部留白

<View style={styles.bottomPadding} />

// styles
bottomPadding: {
  height: 100,
},

为什么需要留白:

这是为了给底部 Tab 栏留出空间,避免最后一个列表被遮挡。用户滚动到底部时,内容能完全显示出来。

小结

首页的核心就是把各种数据聚合展示,让用户一眼就能看到感兴趣的内容。技术上主要是处理好 API 请求的限流问题,以及把重复的 UI 抽成可复用的组件。

整个首页的代码结构还是比较清晰的:状态定义、数据加载、事件处理、UI 渲染,各司其职。后面如果要加新的列表,照着现有的模式加就行了。

下一篇会讲搜索页的实现,涉及到搜索输入和热门搜索推荐的功能。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐