通过网盘分享的文件: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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐