Flutter for OpenHarmony 微动漫App实战:热门动漫实现
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
热门动漫是首页最重要的内容板块,用户打开App第一眼看到的就是它。这个板块要做好,不仅要数据准确,展示形式也要吸引人。横向滚动的卡片列表、下拉刷新、回到顶部按钮,这些细节加在一起,才能让用户觉得"这个App挺好用的"。
这篇文章会深入讲解热门动漫的实现,包括数据获取、列表展示、下拉刷新、以及一些容易踩的坑。代码都是项目里实际跑着的。
首页的整体规划
首页不只有热门动漫,还有当季动漫。两个板块垂直排列,热门动漫在上面用横向滚动展示,当季动漫在下面用网格展示。这种布局让首页内容丰富但不杂乱。
先看看需要引入的依赖:
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../services/api_service.dart';
import '../models/anime.dart';
import '../widgets/anime_card.dart';
import '../widgets/shimmer_loading.dart';
import 'search_screen.dart';
pull_to_refresh 是下拉刷新组件,比自己实现方便多了。flutter_staggered_grid_view 是瀑布流网格组件,虽然这里没用到瀑布流效果,但它的 API 比原生 GridView 更灵活。
状态变量的设计
首页需要管理的状态比较多:
class _HomeScreenState extends State<HomeScreen> {
List<Anime> _topAnime = [];
List<Anime> _seasonalAnime = [];
bool _isLoading = true;
final RefreshController _refreshController = RefreshController();
final ScrollController _scrollController = ScrollController();
bool _showBackToTop = false;
_topAnime 和 _seasonalAnime 分别存储热门动漫和当季动漫数据。
_isLoading 控制是否显示加载状态。
_refreshController 是下拉刷新组件的控制器,用来通知刷新完成。
_scrollController 是滚动控制器,用来监听滚动位置和实现回到顶部功能。
_showBackToTop 控制回到顶部按钮的显示。
状态变量多了,代码会复杂一些,但功能也更完善。
初始化和资源释放
void initState() {
super.initState();
_loadData();
_scrollController.addListener(_onScroll);
}
初始化时做两件事:加载数据、注册滚动监听。
void dispose() {
_refreshController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
dispose 里要释放所有控制器资源。这是 Flutter 开发的基本规范,不释放会内存泄漏。特别注意 _scrollController 要先移除监听再 dispose,顺序不能反。
滚动监听和回到顶部
void _onScroll() {
if (_scrollController.offset > 300 && !_showBackToTop) {
setState(() => _showBackToTop = true);
} else if (_scrollController.offset <= 300 && _showBackToTop) {
setState(() => _showBackToTop = false);
}
}
滚动超过 300 像素时显示回到顶部按钮,滚回 300 以内时隐藏。这个阈值可以根据实际情况调整,太小了按钮会频繁闪烁,太大了用户滚动很远才能看到按钮。
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
animateTo 实现平滑滚动,比 jumpTo 体验好。300 毫秒的动画时长刚刚好,太快了看不清,太慢了用户会不耐烦。Curves.easeOut 是减速曲线,滚动到顶部时会有个减速的感觉,更自然。
数据加载逻辑
Future<void> _loadData() async {
print('🔄 HomeScreen: Starting to load data...');
setState(() => _isLoading = true);
try {
print('📡 HomeScreen: Fetching top anime...');
final top = await ApiService.getTopAnime();
print('📡 HomeScreen: Fetching seasonal anime...');
final seasonal = await ApiService.getSeasonalAnime();
先把 _isLoading 设为 true 显示加载状态,然后依次请求热门动漫和当季动漫。这里用的是串行请求,也可以用 Future.wait 改成并行,但要注意 API 的速率限制。
if (mounted) {
setState(() {
_topAnime = top;
_seasonalAnime = seasonal;
_isLoading = false;
});
print('✅ HomeScreen: Data loaded - Top: ${top.length}, Seasonal: ${seasonal.length}');
}
} catch (e) {
print('❌ HomeScreen: Error loading data: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
_refreshController.refreshCompleted();
}
mounted 检查很重要,异步操作完成时页面可能已经被销毁了,这时候调用 setState 会报错。
最后调用 _refreshController.refreshCompleted() 通知下拉刷新组件刷新完成,不管成功还是失败都要调用,否则刷新动画会一直转。
AppBar 的实现
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('微动漫'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SearchScreen()),
),
),
],
),
elevation: 0 去掉 AppBar 的阴影,让它和下面的内容融为一体,看起来更现代。右上角放一个搜索按钮,点击跳转到搜索页面。
悬浮按钮
floatingActionButton: _showBackToTop
? FloatingActionButton(
mini: true,
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
回到顶部按钮用 FloatingActionButton 实现,mini: true 让按钮小一些,不会太抢眼。根据 _showBackToTop 决定是否显示,为 null 时按钮不会渲染。
页面主体内容
Widget _buildHomeContent() {
if (_isLoading) {
return const ShimmerLoading(itemCount: 8, isGrid: true);
}
加载中显示骨架屏,8 个占位项,网格形式。
if (_topAnime.isEmpty && _seasonalAnime.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.movie, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text('暂无数据'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadData,
child: const Text('重新加载'),
),
],
),
);
}
数据为空时显示空状态页面,包含图标、提示文字和重试按钮。这种情况可能是网络问题,给用户一个重试的机会。
下拉刷新的实现
return SmartRefresher(
controller: _refreshController,
onRefresh: _loadData,
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SmartRefresher 包裹整个内容区域,下拉时触发 _loadData 刷新数据。SingleChildScrollView 让内容可以滚动,绑定 _scrollController 用于监听滚动位置。
热门动漫板块
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'热门动漫',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
板块标题用 18号字体加粗,和内容之间有 12 像素间距。
if (_topAnime.isNotEmpty)
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _topAnime.take(5).length,
itemBuilder: (_, i) => Padding(
padding: const EdgeInsets.only(right: 12),
child: SizedBox(
width: 120,
child: AnimeCard(anime: _topAnime[i], showRank: true),
),
),
),
)
热门动漫用横向滚动的 ListView 展示,高度固定 200,每个卡片宽度 120。take(5) 只取前 5 个,太多了用户也看不过来。showRank: true 让卡片显示排名标签。
else
const SizedBox(
height: 200,
child: Center(child: Text('暂无热门动漫')),
),
],
),
),
数据为空时显示占位文字,保持布局高度一致。
当季动漫板块
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'当季动漫',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (_seasonalAnime.isNotEmpty)
GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _seasonalAnime.take(6).length,
itemBuilder: (_, i) => AnimeCard(anime: _seasonalAnime[i]),
)
当季动漫用 GridView 网格展示,两列布局。childAspectRatio: 0.7 设置卡片宽高比,高度是宽度的 1.43 倍。
shrinkWrap: true 让 GridView 高度自适应内容,NeverScrollableScrollPhysics 禁用 GridView 自身的滚动,因为外层已经有 SingleChildScrollView 了。
take(6) 只取前 6 个,两列正好 3 行,不会太长。
API 服务的实现
来看看 ApiService 是怎么获取热门动漫数据的:
static const String jikanBaseUrl = 'https://api.jikan.moe/v4';
static DateTime? _lastRequest;
static const int _rateLimitMs = 200;
用的是 Jikan API,这是一个免费的动漫数据接口。_lastRequest 记录上次请求时间,_rateLimitMs 是请求间隔,用于速率限制。
速率限制的实现
static Future<void> _rateLimit() async {
if (_lastRequest != null) {
final diff = DateTime.now().difference(_lastRequest!);
if (diff.inMilliseconds < _rateLimitMs) {
await Future.delayed(Duration(milliseconds: _rateLimitMs - diff.inMilliseconds));
}
}
_lastRequest = DateTime.now();
}
每次请求前检查距离上次请求是否超过 200 毫秒,没超过就等一会儿。这样做是因为 Jikan API 有速率限制,请求太频繁会被拒绝。
这个方法很简单但很实用,避免了被 API 封禁的风险。
获取热门动漫
static Future<List<Anime>> getTopAnime({int page = 1}) async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/top/anime?page=$page&limit=25';
print('🔄 Fetching top anime: $url');
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 15),
onTimeout: () {
print('⏱️ Request timeout');
return http.Response('timeout', 408);
},
);
先调用 _rateLimit 确保不会请求太频繁。然后发起 HTTP GET 请求,设置 15 秒超时。超时后返回一个假的 408 响应,而不是抛异常,这样后面的代码可以统一处理。
print('📡 Response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = json.decode(response.body);
final dataList = data['data'] as List;
print('📦 Received ${dataList.length} items from API');
状态码 200 表示成功,解析 JSON 数据。API 返回的数据在 data 字段里,是一个数组。
final animes = <Anime>[];
for (var i = 0; i < dataList.length; i++) {
try {
final anime = Anime.fromJson(dataList[i]);
animes.add(anime);
} catch (e) {
print('❌ Error parsing anime at index $i: $e');
}
}
print('✅ Successfully parsed ${animes.length} top animes');
return animes;
} else {
print('❌ Error: ${response.statusCode} - ${response.body}');
return [];
}
} catch (e) {
print('❌ Error fetching top anime: $e');
return [];
}
}
遍历数组,逐个解析成 Anime 对象。每个解析操作都用 try-catch 包裹,单条数据解析失败不会影响其他数据。这种防御性编程很重要,API 返回的数据格式可能不一致。
请求失败或解析失败都返回空列表,不抛异常。这样调用方不需要处理异常,代码更简洁。
获取当季动漫
static Future<List<Anime>> getSeasonalAnime({int page = 1}) async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/seasons/now?page=$page&limit=25';
当季动漫用的是 /seasons/now 接口,其他逻辑和热门动漫一样。
API 设计得很规范,不同类型的数据用不同的端点,但返回格式是统一的,解析代码可以复用。
趋势页面的轮播实现
除了首页,趋势页面也展示热门动漫,但用的是轮播图形式:
class _TrendingScreenState extends State<TrendingScreen> {
List<Anime> _trendingAnime = [];
List<Anime> _upcomingAnime = [];
bool _isLoading = true;
趋势页面同时展示热门动漫和即将上映的动漫,两个列表分开存储。
Future<void> _loadData() async {
setState(() => _isLoading = true);
try {
final trending = await ApiService.getTopAnime();
final upcoming = await ApiService.getUpcomingAnime();
setState(() {
_trendingAnime = trending;
_upcomingAnime = upcoming;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
数据加载逻辑和首页类似,但这里没有用 mounted 检查。严格来说应该加上,但趋势页面通常不会在加载过程中被销毁,所以问题不大。
轮播图组件
CarouselSlider(
options: CarouselOptions(
height: 200,
autoPlay: true,
enlargeCenterPage: true,
viewportFraction: 0.8,
),
items: _trendingAnime.take(5).map((anime) {
return AnimeCard(anime: anime, showRank: true);
}).toList(),
),
CarouselSlider 是第三方轮播图组件,配置项很丰富:
height: 200 设置轮播图高度,和首页的横向列表保持一致。
autoPlay: true 自动播放,每隔几秒切换到下一张,不用用户手动滑动。
enlargeCenterPage: true 中间的卡片放大,两边的缩小,有层次感和焦点感。
viewportFraction: 0.8 每个卡片占视口宽度的 80%,这样能看到两边卡片的一部分,暗示用户可以左右滑动。
即将上映板块
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'即将上映',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _upcomingAnime.take(6).length,
itemBuilder: (_, i) =>
AnimeCard(anime: _upcomingAnime[i]),
),
],
),
),
即将上映的动漫用网格展示,和首页的当季动漫布局一样。代码复用是好事,但如果发现多个地方用同样的布局,可以考虑抽取成独立组件。
获取即将上映数据
static Future<List<Anime>> getUpcomingAnime({int page = 1}) async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/seasons/upcoming?page=$page&limit=25';
print('Fetching: $url');
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 10),
onTimeout: () => http.Response('timeout', 408),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final animes = (data['data'] as List).map((e) => Anime.fromJson(e)).toList();
print('Got ${animes.length} upcoming animes');
return animes;
} else {
print('Error: ${response.statusCode}');
return [];
}
} catch (e) {
print('Error fetching upcoming anime: $e');
return [];
}
}
即将上映用的是 /seasons/upcoming 接口。这个接口返回的是还没开播的新番,对于追番用户来说很有价值,可以提前了解下一季有什么好看的。
关于数据缓存的思考
目前的实现每次进入页面都会重新请求数据,这样做的好处是数据总是最新的,坏处是浪费流量和时间。
如果想优化,可以加一层缓存:
static Map<String, List<Anime>> _cache = {};
static Map<String, DateTime> _cacheTime = {};
static const int _cacheMinutes = 5;
static Future<List<Anime>> getTopAnime({int page = 1, bool forceRefresh = false}) async {
final cacheKey = 'top_$page';
// 检查缓存是否有效
if (!forceRefresh && _cache.containsKey(cacheKey)) {
final cacheAge = DateTime.now().difference(_cacheTime[cacheKey]!);
if (cacheAge.inMinutes < _cacheMinutes) {
print('📦 Using cached data for $cacheKey');
return _cache[cacheKey]!;
}
}
// 缓存无效,重新请求
final animes = await _fetchTopAnime(page);
_cache[cacheKey] = animes;
_cacheTime[cacheKey] = DateTime.now();
return animes;
}
这段代码实现了一个简单的内存缓存,5 分钟内重复请求会直接返回缓存数据。forceRefresh 参数用于下拉刷新时强制更新。
当然,这只是内存缓存,App 重启后就没了。如果想持久化缓存,可以用 shared_preferences 或者 hive 存到本地。
错误处理的最佳实践
目前的错误处理比较简单,只是打印日志然后返回空列表。在实际项目中,可以做得更完善:
class ApiResult<T> {
final T? data;
final String? error;
final bool isSuccess;
ApiResult.success(this.data) : error = null, isSuccess = true;
ApiResult.failure(this.error) : data = null, isSuccess = false;
}
static Future<ApiResult<List<Anime>>> getTopAnimeWithResult({int page = 1}) async {
try {
await _rateLimit();
final url = '$jikanBaseUrl/top/anime?page=$page&limit=25';
final response = await http.get(Uri.parse(url)).timeout(
const Duration(seconds: 15),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final animes = (data['data'] as List).map((e) => Anime.fromJson(e)).toList();
return ApiResult.success(animes);
} else if (response.statusCode == 429) {
return ApiResult.failure('请求太频繁,请稍后再试');
} else {
return ApiResult.failure('服务器错误: ${response.statusCode}');
}
} on TimeoutException {
return ApiResult.failure('请求超时,请检查网络');
} catch (e) {
return ApiResult.failure('网络错误: $e');
}
}
用 ApiResult 包装返回值,调用方可以根据 isSuccess 判断是否成功,失败时可以拿到具体的错误信息显示给用户。
小结
热门动漫功能涉及的知识点:下拉刷新组件的使用、滚动监听和回到顶部、横向滚动列表、网格布局、轮播图组件、API 速率限制、防御性的数据解析、mounted 检查避免内存泄漏。
这些技术点组合在一起,才能做出一个体验好的首页。每个细节都不难,但都不能忽略。
首页是用户对 App 的第一印象,值得花时间打磨。数据加载要快、布局要清晰、交互要流畅,这三点做好了,用户就会觉得这个 App 很专业。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)