RN for OpenHarmony AnimeHub项目实战:打造动漫应用首页
案例开源地址:
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- 稍微亮一点,用于卡片或者需要区分层次的地方text和textSecondary- 分别是主要文字和次要文字的颜色,形成层次对比
把这些值抽出来统一管理,后面改起来方便,整个应用的视觉风格也能保持一致。如果哪天想换个主题色,改一个地方就行了。
间距和圆角也做了统一定义:
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- 标题前面的小图标,让标题更生动(可选)
问号表示可选参数,onPressMore 和 icon 不传也行,组件内部会判断。这样设计的好处是灵活,有些地方可能不需要"更多"按钮,直接不传就行。
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 显示,初始值是 truerefreshing- 控制下拉刷新时的刷新指示器,初始值是 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
更多推荐



所有评论(0)