RN for OpenHarmony AnimeHub项目实战:季度动漫页面开发

案例开源地址:https://atomgit.com/nutpi/Rn_openharmony_AnimeHub
季度动漫页展示某个特定季度的所有动漫作品。用户从全部季度页点击某个季度后,会跳转到这个页面查看该季度的动漫列表。这篇来讲季度动漫页的实现,重点是路由参数的使用和分页加载。
功能设计
季度动漫页需要实现以下功能:
- 动态标题 - 根据年份和季度显示标题,如"2024年春季"
- 网格展示 - 两列网格展示动漫卡片
- 分页加载 - 滚动到底部自动加载更多
- 空状态处理 - 没有数据时显示友好提示
这个页面是全部季度页的下一级页面,两者配合使用。用户在全部季度页选择一个时间点,然后在这个页面浏览该时间点的所有动漫。这种"先选择再浏览"的模式在内容型应用中非常常见。
季度动漫页的数据量可能很大。热门季度(如 2024 年春季)可能有 50+ 部动漫,所以必须实现分页加载,不能一次性加载全部数据。
季节名称映射
和全部季度页一样,需要定义季节的中文名称:
const SEASON_NAMES: Record<string, string> = {
winter: '冬季',
spring: '春季',
summer: '夏季',
fall: '秋季',
};
映射说明:
- 这个映射用于生成页面标题
- API 返回的是英文季节名,需要转换为中文显示
- 用
Record<string, string>类型确保类型安全
你可能会问:这个映射在全部季度页已经定义过了,为什么不抽取成公共模块?这是一个好问题。在实际项目中,如果多个页面都用到同样的映射,确实应该抽取到
src/constants或src/utils目录。但在这个教程项目中,为了让每个页面的代码独立完整,我们选择在每个页面单独定义。
代码复用和代码独立性是一对矛盾。复用可以减少重复,但会增加依赖;独立可以降低耦合,但会有重复代码。需要根据项目规模和团队习惯来权衡。
获取路由参数
从路由中获取年份和季度参数:
export const SeasonAnimeScreen = ({ navigation, route }: any) => {
const { year, season } = route.params;
参数说明:
navigation- 导航对象,用于页面跳转和返回route- 路由对象,包含传递过来的参数year- 年份,数字类型,如 2024season- 季节,字符串类型,如 ‘spring’
route.params是 React Navigation 传递参数的标准方式。上一个页面通过navigation.navigate('SeasonAnime', { year: 2024, season: 'spring' })传递参数,这个页面通过route.params接收。
这里用了
any类型是为了简化代码。在正式项目中,应该定义完整的类型:type RouteParams = { year: number; season: string; };然后用
route.params as RouteParams或者配合 React Navigation 的类型系统使用。
状态定义
这个页面需要管理多个状态:
const [animeList, setAnimeList] = useState<Anime[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
状态详解:
animeList- 动漫列表数据,Anime 类型的数组loading- 首次加载状态,控制全屏 LoadingloadingMore- 加载更多状态,控制底部 Loadingpage- 当前页码,从 1 开始hasMore- 是否还有更多数据,控制是否继续加载
为什么要区分
loading和loadingMore?因为用户体验不同:
- 首次加载时,页面是空的,需要显示全屏 Loading,告诉用户"正在加载"
- 加载更多时,页面已经有内容了,只需要在底部显示小的 Loading,不影响用户浏览已有内容
hasMore状态非常重要。如果不判断是否还有更多数据,用户滚动到底部时会不断发起请求,即使已经没有数据了。这会浪费网络资源,也会给服务器带来不必要的压力。
数据加载函数
核心的数据加载逻辑:
const loadData = async (pageNum: number, append = false) => {
try {
if (pageNum === 1) setLoading(true);
else setLoadingMore(true);
const res = await getSeasonAnime(year, season, pageNum);
const newData = res.data || [];
函数参数:
pageNum- 要加载的页码append- 是否追加到现有数据,默认 false(替换)
加载状态控制:
- 第一页时设置
loading(全屏 Loading) - 其他页时设置
loadingMore(底部 Loading)
append参数的设计很巧妙。首次加载时append = false,数据会替换现有列表;加载更多时append = true,新数据会追加到列表末尾。一个函数处理两种场景,代码更简洁。
getSeasonAnime(year, season, pageNum)是封装好的 API 函数,传入年份、季节和页码,返回该季度的动漫列表。API 会根据页码返回对应的数据片段。
if (append) {
setAnimeList(prev => [...prev, ...newData]);
} else {
setAnimeList(newData);
}
setHasMore(res.pagination?.has_next_page || false);
数据处理:
- 追加模式:用展开运算符合并旧数据和新数据
- 替换模式:直接设置新数据
- 从 API 响应中获取是否还有下一页
setAnimeList(prev => [...prev, ...newData])是 React 状态更新的函数式写法。prev是当前状态值,返回值是新状态。这种写法比setAnimeList([...animeList, ...newData])更安全,因为它总是基于最新的状态值。
res.pagination?.has_next_page使用了可选链操作符?.。如果res.pagination是 undefined 或 null,整个表达式返回 undefined,不会报错。然后|| false确保最终值是布尔类型。
} catch (error) {
console.error('Load error:', error);
} finally {
setLoading(false);
setLoadingMore(false);
}
};
错误处理和清理:
- catch 捕获错误并打印日志
- finally 确保无论成功失败都重置加载状态
finally块是清理工作的最佳位置。无论 try 块成功还是 catch 块执行,finally 都会执行。这保证了加载状态一定会被重置,不会出现"永远在加载"的 bug。
初始加载
组件挂载时加载第一页数据:
useEffect(() => {
loadData(1);
}, [year, season]);
依赖数组说明:
- 依赖
year和season - 当年份或季节变化时,重新加载数据
为什么依赖数组包含
year和season?虽然在当前实现中,这两个值来自路由参数,不会变化。但如果将来页面支持切换季度(比如添加上一季/下一季按钮),这个依赖就会起作用。
这是一种"防御性编程"的思想:即使当前不需要,也为将来的扩展做好准备。
加载更多处理
滚动到底部时触发加载更多:
const handleLoadMore = useCallback(() => {
if (!loadingMore && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
loadData(nextPage, true);
}
}, [loadingMore, hasMore, page]);
加载条件:
!loadingMore- 当前没有在加载中(防止重复请求)hasMore- 还有更多数据可以加载
加载流程:
- 计算下一页页码
- 更新页码状态
- 调用 loadData 加载数据,append 设为 true
useCallback是 React 的性能优化 Hook。它会缓存函数引用,只有当依赖项变化时才创建新函数。这对于传递给子组件的回调函数特别有用,可以避免不必要的重渲染。
为什么要检查
!loadingMore?因为 FlatList 的onEndReached可能会被多次触发。如果不检查,用户快速滚动时可能会发起多个重复请求。这个检查确保同一时间只有一个加载请求在进行。
列表项渲染
渲染每个动漫卡片:
const renderItem = ({ item }: { item: Anime }) => (
<View style={styles.cardWrapper}>
<AnimeCard
anime={item}
onPress={() => navigation.navigate('AnimeDetail', { animeId: item.mal_id })}
/>
</View>
);
渲染逻辑:
- 外层
cardWrapper控制卡片的宽度和间距 AnimeCard是封装好的动漫卡片组件- 点击卡片跳转到动漫详情页
为什么需要
cardWrapper?因为 FlatList 的numColumns={2}只是把列表分成两列,但不会自动处理卡片之间的间距。cardWrapper用来控制每个卡片占据的空间和内边距。
item.mal_id是动漫在 MyAnimeList 数据库中的唯一 ID。传递这个 ID 到详情页,详情页就可以用它来请求完整的动漫信息。
动态标题生成
根据路由参数生成页面标题:
const title = `${year}年${SEASON_NAMES[season] || season}`;
标题格式:
- 组合年份和季节名称
- 如果季节名称映射不存在,使用原始英文名
- 最终效果如"2024年春季"
SEASON_NAMES[season] || season是一个容错处理。正常情况下,season 的值是 ‘winter’、‘spring’、‘summer’ 或 ‘fall’,都能在映射中找到。但如果 API 返回了意外的值,用|| season确保至少能显示原始值,不会显示 undefined。
这种"优雅降级"的思想在前端开发中很重要。即使数据不符合预期,也要尽量让页面正常显示,而不是崩溃或显示空白。
加载状态渲染
首次加载时显示全屏 Loading:
if (loading) {
return (
<View style={styles.container}>
<Header title={title} showBack onBack={() => navigation.goBack()} />
<Loading fullScreen text="加载中..." />
</View>
);
}
设计考虑:
- 即使在加载中,也显示 Header,让用户知道自己在哪个页面
- 用户可以点击返回按钮取消加载
- Loading 组件显示"加载中…"文字
为什么加载时也要显示 Header?这是用户体验的细节。如果整个页面都是 Loading,用户会感到迷茫:"我在哪?这是什么页面?"显示 Header 可以给用户一个锚点,让他们知道自己的位置。
另外,显示返回按钮让用户有"退路"。如果加载太慢或者用户改变主意,可以随时返回。这种"可控感"对用户体验很重要。
主页面结构
加载完成后显示动漫列表:
return (
<View style={styles.container}>
<Header title={title} showBack onBack={() => navigation.goBack()} />
<FlatList
data={animeList}
renderItem={renderItem}
keyExtractor={item => item.mal_id.toString()}
numColumns={2}
contentContainerStyle={styles.list}
showsVerticalScrollIndicator={false}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={loadingMore ? <Loading text="加载更多..." /> : null}
ListEmptyComponent={<EmptyState icon="movie" title="暂无数据" />}
/>
</View>
);
FlatList 配置详解:
data- 数据源renderItem- 渲染函数keyExtractor- 提取唯一 keynumColumns={2}- 两列网格布局onEndReached- 滚动到底部的回调onEndReachedThreshold={0.5}- 触发阈值,0.5 表示距离底部 50% 时触发ListFooterComponent- 列表底部组件ListEmptyComponent- 空列表时显示的组件
onEndReachedThreshold={0.5}是一个重要的配置。值越大,触发加载的时机越早。0.5 意味着当用户滚动到列表一半时就开始加载下一页,这样等用户滚动到底部时,新数据可能已经加载好了,体验更流畅。
如果设置太小(如 0.1),用户会看到明显的加载等待;如果设置太大(如 0.9),可能会加载很多用户不会看的数据,浪费流量。0.5 是一个比较平衡的值。
底部加载组件
ListFooterComponent={loadingMore ? <Loading text="加载更多..." /> : null}
条件渲染:
- 正在加载更多时显示 Loading 组件
- 不在加载时显示 null(不显示任何内容)
这个 Loading 组件和全屏 Loading 不同,它只是一个小的加载指示器,显示在列表底部。用户可以继续浏览已加载的内容,同时知道更多内容正在加载。
空状态组件
ListEmptyComponent={<EmptyState icon="movie" title="暂无数据" />}
空状态处理:
- 当
animeList为空数组时显示 - 显示一个图标和"暂无数据"文字
空状态在什么情况下会出现?
- 很早的年份(如 1960 年代某些季度)可能没有动漫数据
- API 返回错误时,数据可能为空
- 网络问题导致数据加载失败
显示友好的空状态比显示空白页面好得多。空白页面会让用户困惑:"是没有数据还是出错了?"而明确的"暂无数据"提示让用户知道这是正常情况。
样式定义
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background,
},
容器样式:
flex: 1占满整个屏幕- 使用主题背景色
list: {
padding: Spacing.sm,
},
列表样式:
- 四周有小内边距
- 让内容不贴边
cardWrapper: {
flex: 1,
maxWidth: '50%',
padding: Spacing.xs,
},
});
卡片包装样式:
flex: 1让卡片平分宽度maxWidth: '50%'确保每行最多两个卡片padding: Spacing.xs卡片之间的间距
maxWidth: '50%'和numColumns={2}配合使用。FlatList 会把每行分成两列,每个卡片占一列。maxWidth: '50%'确保卡片不会超过一半宽度,即使内容很多也不会撑开。
为什么用
padding而不是margin?在 Flexbox 布局中,padding 不会影响元素的 flex 计算,而 margin 会。用 padding 可以让两个卡片之间有间距,同时保持flex: 1的等宽效果。
与全部季度页的配合
这两个页面形成了一个完整的浏览流程:
- 用户进入全部季度页,看到所有年份和季度
- 用户点击某个季度(如"2024年春季")
- 跳转到季度动漫页,显示该季度的所有动漫
- 用户可以滚动浏览,点击某个动漫进入详情页
这是一种典型的"列表 → 详情"导航模式,但多了一层"分类 → 列表"。这种三层结构在内容型应用中很常见:
- 第一层:分类/筛选(全部季度页)
- 第二层:列表(季度动漫页)
- 第三层:详情(动漫详情页)
分页加载的最佳实践
这个页面展示了分页加载的标准实现:
- 状态分离 - 首次加载和加载更多用不同的状态
- 防重复请求 - 检查 loadingMore 状态
- 边界判断 - 检查 hasMore 状态
- 数据追加 - 用展开运算符合并数据
- 提前加载 - 设置合适的 threshold
分页加载是移动端开发的基本功。几乎所有的列表页面都需要分页,因为一次性加载所有数据会导致:
- 首屏加载慢
- 内存占用高
- 渲染性能差
- 浪费用户流量
小结
季度动漫页是一个标准的分页列表页面,展示了路由参数获取、分页加载、条件渲染等常用技术。页面从路由获取年份和季度参数,动态生成标题,然后加载对应的动漫数据。
分页加载的实现包括状态管理(loading、loadingMore、page、hasMore)、加载函数(支持首次加载和追加加载)、触发机制(onEndReached)和边界判断(防重复、判断是否还有更多)。
两列网格布局用 numColumns={2} 和 cardWrapper 样式配合实现,卡片等宽分布,间距均匀。空状态和加载状态都有友好的 UI 反馈,提升用户体验。
下一篇会讲热门排行页面,展示评分最高的动漫作品。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)