Flutter for OpenHarmony 微动漫App实战 - 首页实现
本文详细介绍了微动漫App首页的实现过程,主要包含以下核心内容: 页面结构设计 采用经典的内容聚合布局 顶部横向滚动热门推荐 下方网格展示当季新番 关键功能实现 数据加载:通过ApiService获取动漫数据 状态管理:使用StatefulWidget管理加载状态 交互体验:实现下拉刷新、骨架屏和空状态处理 导航功能:顶部搜索入口和返回顶部按钮 性能优化 使用mounted检查避免内存泄漏 完善的
通过网盘分享的文件:flutter1.zip
链接: https://pan.baidu.com/s/1jkLZ9mZXjNm0LgP6FTVRzw 提取码: 2t97
首页是用户打开应用后最常停留的页面,它的设计直接影响用户对整个应用的印象。微动漫App的首页采用了经典的内容聚合布局:顶部是横向滚动的热门动漫推荐,下方是网格展示的当季新番。本文将完整讲解首页的实现过程,包括数据加载、下拉刷新、骨架屏、空状态处理等实用功能。
首页的整体设计思路
在开始写代码之前,我们先梳理一下首页需要实现的功能:
- 展示热门动漫列表,支持横向滚动浏览
- 展示当季动漫列表,使用网格布局
- 支持下拉刷新,重新加载数据
- 加载过程中显示骨架屏,提升用户体验
- 数据为空时显示友好的空状态提示
- 顶部导航栏带搜索入口
这些功能组合在一起,构成了一个完整的首页体验。接下来我们逐步实现。
页面基础结构
首页是一个有状态的组件,因为需要管理数据加载状态、动漫列表等状态。
import 'package:flutter/material.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:第三方下拉刷新组件,提供了比原生更丰富的刷新效果ApiService:封装好的接口服务,用于获取动漫数据Anime:动漫数据模型AnimeCard:动漫卡片组件,用于展示单个动漫ShimmerLoading:骨架屏组件,加载时显示SearchScreen:搜索页面,点击搜索图标跳转
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
State<HomeScreen> createState() => _HomeScreenState();
}
标准的
StatefulWidget定义。首页需要管理多个状态,所以必须使用有状态组件。
状态变量的定义
首页需要管理的状态比较多,我们逐一声明:
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:是否显示回到顶部按钮变量命名以下划线开头表示私有,这是 Dart 的约定。私有变量只能在当前文件内访问。
初始化与生命周期
组件创建时需要加载数据,销毁时需要释放资源:
void initState() {
super.initState();
_loadData();
_scrollController.addListener(_onScroll);
}
initState中做两件事:
- 调用
_loadData()开始加载首页数据- 给滚动控制器添加监听,用于实现回到顶部功能
数据加载放在
initState而不是构造函数中,是因为initState是组件生命周期的正式开始,此时组件已经被插入到 Widget 树中,可以安全地进行各种操作。
void dispose() {
_refreshController.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
dispose中释放所有资源:
- 销毁刷新控制器
- 移除滚动监听
- 销毁滚动控制器
资源释放的顺序很重要:先移除监听器,再销毁控制器。如果顺序反了,可能会在销毁后还尝试调用监听器,导致错误。
数据加载逻辑
数据加载是首页最核心的功能。我们需要同时获取热门动漫和当季动漫两个列表。
Future<void> _loadData() async {
print('🔄 HomeScreen: Starting to load data...');
setState(() => _isLoading = true);
加载开始时,先将
_isLoading设为true,这会触发界面显示骨架屏。
try {
print('📡 HomeScreen: Fetching top anime...');
final top = await ApiService.getTopAnime();
print('📡 HomeScreen: Fetching seasonal anime...');
final seasonal = await ApiService.getSeasonalAnime();
使用
await依次获取两个列表。这里是串行请求,如果想要并行请求可以使用Future.wait:final results = await Future.wait([ ApiService.getTopAnime(), ApiService.getSeasonalAnime(), ]); final top = results[0]; final seasonal = results[1];并行请求可以减少总的等待时间,但代码稍微复杂一些。根据实际情况选择。
if (mounted) {
setState(() {
_topAnime = top;
_seasonalAnime = seasonal;
_isLoading = false;
});
print('✅ HomeScreen: Data loaded - Top: ${top.length}, Seasonal: ${seasonal.length}');
}
mounted检查非常重要。在异步操作完成时,组件可能已经被销毁(比如用户快速切换页面)。如果不检查就直接调用setState,会抛出异常。数据加载成功后,更新状态变量并将
_isLoading设为false,界面会从骨架屏切换到实际内容。
} catch (e) {
print('❌ HomeScreen: Error loading data: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
_refreshController.refreshCompleted();
}
错误处理:即使加载失败,也要将
_isLoading设为false,否则用户会一直看到骨架屏。
_refreshController.refreshCompleted():通知刷新控制器刷新已完成,让刷新指示器收起。无论成功还是失败都要调用,否则刷新指示器会一直显示。
页面主体结构
首页使用 Scaffold 作为基础框架:
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()),
),
),
],
),
body: _buildHomeContent(),
floatingActionButton: _showBackToTop
? FloatingActionButton(
mini: true,
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
AppBar 配置:
title:应用名称"微动漫"elevation: 0:去掉阴影,让导航栏看起来更扁平actions:右侧操作按钮,这里放了一个搜索图标搜索按钮:点击后使用
Navigator.push跳转到搜索页面。MaterialPageRoute提供了标准的页面切换动画。悬浮按钮:根据
_showBackToTop状态决定是否显示回到顶部按钮。使用三元表达式,当不需要显示时返回null。
内容区域的三种状态
首页内容区域有三种状态:加载中、数据为空、正常显示。我们用一个方法来处理这三种情况:
Widget _buildHomeContent() {
if (_isLoading) {
return const ShimmerLoading(itemCount: 8, isGrid: true);
}
加载状态:显示骨架屏。
ShimmerLoading是我们自定义的骨架屏组件,itemCount: 8表示显示 8 个占位卡片,isGrid: true表示使用网格布局。骨架屏比简单的加载圈更友好,它让用户预知内容的大致布局,减少等待的焦虑感。
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('重新加载'),
),
],
),
);
}
空状态:当两个列表都为空时,显示一个友好的空状态提示。
空状态的设计要点:
- 一个相关的图标,让用户知道这是什么类型的内容
- 简短的文字说明
- 一个操作按钮,让用户可以尝试重新加载
这比只显示一行"暂无数据"要友好得多。用户看到按钮会知道可以做什么,而不是茫然不知所措。
下拉刷新的实现
正常状态下,内容区域使用 SmartRefresher 包裹,实现下拉刷新功能:
return SmartRefresher(
controller: _refreshController,
onRefresh: _loadData,
child: SingleChildScrollView(
controller: _scrollController,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SmartRefresher是pull_to_refresh包提供的组件,比 Flutter 原生的RefreshIndicator功能更丰富。
controller:刷新控制器,用于控制刷新状态onRefresh:下拉刷新时的回调,这里直接调用_loadData
SingleChildScrollView让整个内容区域可以滚动。我们把滚动控制器绑定到这里,用于实现回到顶部功能。
crossAxisAlignment: CrossAxisAlignment.start:让 Column 的子组件左对齐。
热门动漫横向列表
首页顶部是热门动漫的横向滚动列表:
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),
每个区块都用
Padding包裹,保持统一的边距。标题使用 18 号粗体字,与内容形成层次对比。
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),
),
),
),
)
横向列表的实现要点:
SizedBox(height: 200):必须给横向列表一个固定高度,否则 Flutter 不知道该分配多少空间scrollDirection: Axis.horizontal:设置为横向滚动_topAnime.take(5):只取前 5 个,首页不需要显示太多width: 120:每个卡片宽度固定为 120 像素showRank: true:显示排名标签,因为这是热门榜单卡片之间使用
Padding添加 12 像素的右边距,让卡片之间有适当的间隔。
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]),
)
网格布局的关键配置:
crossAxisCount: 2:每行显示 2 个卡片childAspectRatio: 0.7:卡片宽高比为 0.7,即高度是宽度的 1.43 倍,适合海报比例crossAxisSpacing: 12:列间距 12 像素mainAxisSpacing: 12:行间距 12 像素
shrinkWrap: true非常重要。默认情况下,GridView会尽可能占据所有可用空间。但我们的GridView是放在SingleChildScrollView里面的,如果不设置shrinkWrap,会导致布局冲突。设置为true后,GridView只会占据实际内容需要的高度。
physics: NeverScrollableScrollPhysics()禁用GridView自身的滚动。因为外层已经有SingleChildScrollView处理滚动了,如果GridView也能滚动,会造成滚动冲突,用户体验很差。
else
const SizedBox(
height: 200,
child: Center(child: Text('暂无当季动漫')),
),
],
),
),
],
),
),
);
}
同样,当季列表为空时显示占位提示。
骨架屏组件详解
骨架屏是提升加载体验的重要手段。我们来看看 ShimmerLoading 组件的实现:
class ShimmerLoading extends StatelessWidget {
final int itemCount;
final bool isGrid;
const ShimmerLoading({super.key, this.itemCount = 6, this.isGrid = true});
骨架屏组件接收两个参数:
itemCount:显示多少个占位卡片isGrid:是否使用网格布局,否则使用列表布局
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark ? Colors.grey[800]! : Colors.grey[300]!;
final highlightColor = isDark ? Colors.grey[700]! : Colors.grey[100]!;
适配深色模式:骨架屏的颜色需要根据当前主题调整。深色模式下使用深灰色,浅色模式下使用浅灰色。
Shimmer效果需要两个颜色:
baseColor:基础颜色,占位块的默认颜色highlightColor:高亮颜色,闪烁动画经过时的颜色
if (isGrid) {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: itemCount,
itemBuilder: (_, __) => Shimmer.fromColors(
baseColor: baseColor,
highlightColor: highlightColor,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
网格布局的骨架屏,配置与实际内容的
GridView保持一致,这样加载完成后切换时不会有布局跳动。
Shimmer.fromColors是shimmer包提供的组件,它会给子组件添加闪烁动画效果。子组件是一个圆角矩形,模拟卡片的形状。
回到顶部功能
首页内容较多时,用户可能需要快速回到顶部。这个功能的实现在第02篇已经详细讲过,这里简单回顾:
void _onScroll() {
if (_scrollController.offset > 300 && !_showBackToTop) {
setState(() => _showBackToTop = true);
} else if (_scrollController.offset <= 300 && _showBackToTop) {
setState(() => _showBackToTop = false);
}
}
void _scrollToTop() {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
滚动超过 300 像素时显示按钮,点击按钮平滑滚动回顶部。
一些优化建议
首页作为应用的门面,有一些优化点值得考虑:
数据缓存
每次进入首页都重新加载数据会消耗流量和时间。可以考虑将数据缓存到本地,下次进入时先显示缓存数据,同时在后台刷新。
图片预加载
横向列表中的图片可以预加载,这样用户滑动时不会看到加载过程。Flutter 提供了
precacheImage方法可以实现这个功能。
分页加载
当前实现只加载固定数量的数据。如果数据量大,可以实现分页加载,用户滚动到底部时自动加载更多。
错误重试
当前的错误处理比较简单,只是停止加载。可以添加自动重试机制,或者显示更详细的错误信息和重试按钮。
小结
首页的实现涉及到很多 Flutter 开发中的常见模式:
- 状态管理:使用
StatefulWidget管理加载状态和数据 - 异步数据加载:使用
async/await获取数据,注意mounted检查 - 下拉刷新:使用
SmartRefresher实现下拉刷新功能 - 多种布局:横向列表用
ListView,网格用GridView - 嵌套滚动:
shrinkWrap和NeverScrollableScrollPhysics的配合使用 - 骨架屏:使用
Shimmer提升加载体验 - 空状态处理:友好的空状态提示和操作引导
这些模式在其他页面的开发中也会反复用到,掌握了首页的实现,其他页面就会轻松很多。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)