在这里插入图片描述

首页是应用的核心页面,包含分类导航、新闻列表、下拉刷新等功能。做好首页需要考虑状态管理、数据缓存、性能优化等多个方面。

首页功能需求

在动手之前,先梳理一下首页的功能需求:

分类导航 - 用户可以快速切换不同的新闻分类
新闻列表 - 展示当前分类的新闻,支持滚动
下拉刷新 - 用户下拉列表可以刷新数据
状态管理 - 处理加载中、加载失败、空数据等各种状态
数据缓存 - 切换分类后再切回来,不应该重新加载数据
搜索入口 - 顶部提供搜索按钮

导入依赖

创建lib/screens/home/home_screen.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/news_provider.dart';
import '../../widgets/news_card.dart';
import '../../widgets/category_chip.dart';
import '../search/search_screen.dart';

代码解析

  • provider - 状态管理库,用于访问NewsProvider
  • news_provider - 管理新闻数据和加载状态
  • news_card - 新闻卡片组件,展示单条新闻
  • category_chip - 分类标签组件,展示分类按钮
  • search_screen - 搜索页面

定义首页结构

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final List<Map<String, dynamic>> _categories = [
    {'name': '航天', 'key': 'space', 'icon': Icons.rocket_launch},
    {'name': '科技', 'key': 'tech', 'icon': Icons.computer},
    {'name': '体育', 'key': 'sports', 'icon': Icons.sports_soccer},
    {'name': '娱乐', 'key': 'entertainment', 'icon': Icons.movie},
    {'name': '商业', 'key': 'business', 'icon': Icons.business},
    {'name': '健康', 'key': 'health', 'icon': Icons.health_and_safety},
    {'name': '科学', 'key': 'science', 'icon': Icons.science},
  ];

  String _selectedCategory = 'space';

代码解析

  • _categories - 分类列表,包含名称、key和图标
  • name - 显示给用户看的中文名称
  • key - 用于请求API的标识符,用英文
  • icon - 分类图标,让界面更直观
  • _selectedCategory - 当前选中的分类,默认航天

为什么用Map而不是定义一个类:对于这种简单的数据结构,用Map就够了。如果数据结构更复杂,或者需要方法,再考虑定义类。不要过度设计。

初始化数据加载

  
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      context.read<NewsProvider>().fetchNews(_selectedCategory);
    });
  }

代码解析

  • addPostFrameCallback - 在第一帧渲染完成后执行
  • 不能在initState直接使用context.read,因为Widget树还没构建完成
  • 只会执行一次,比didChangeDependencies更可控

为什么用addPostFrameCallback:这是个很重要的技巧。在initState里不能直接使用context.read,因为此时Widget树还没构建完成。addPostFrameCallback会在第一帧渲染完成后执行,这时候就可以安全地访问Provider了。

构建AppBar

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('今日资讯'),
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(builder: (_) => const SearchScreen()),
              );
            },
          ),
          IconButton(
            icon: const Icon(Icons.notifications_outlined),
            onPressed: () {
              // TODO: Notifications
            },
          ),
        ],
      ),

代码解析

  • 搜索按钮跳转到搜索页面,放在右上角,这是用户习惯的位置
  • 通知按钮预留接口,虽然现在还没实现,但先把入口留好
  • 使用Navigator.push保留返回路径,用户可以返回首页

构建页面布局

      body: Column(
        children: [
          _buildCategoryTabs(),
          Expanded(
            child: _buildNewsList(),
          ),
        ],
      ),
    );
  }

代码解析

  • Column垂直排列分类标签栏和新闻列表
  • Expanded让新闻列表占据剩余空间
  • 分类标签栏高度固定,新闻列表可滚动

实现分类标签栏

  Widget _buildCategoryTabs() {
    return Container(
      height: 60,
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16),
        itemCount: _categories.length,
        itemBuilder: (context, index) {
          final category = _categories[index];
          final isSelected = _selectedCategory == category['key'];
          
          return Padding(
            padding: const EdgeInsets.only(right: 8),
            child: CategoryChip(
              label: category['name'],
              icon: category['icon'],
              isSelected: isSelected,
              onTap: () {
                setState(() {
                  _selectedCategory = category['key'];
                });
                context.read<NewsProvider>().fetchNews(_selectedCategory);
              },
            ),
          );
        },
      ),
    );
  }

代码解析

  • 固定高度60,上下padding 8,这个高度刚好能容纳图标和文字
  • ListView.builder横向滚动,按需构建,性能好
  • scrollDirection: Axis.horizontal - 设置横向滚动,默认是纵向的
  • 点击标签时更新选中状态并加载数据
  • setState触发UI更新
  • 每个标签右边加8像素间距,让标签之间不会挨得太紧

实现新闻列表

  Widget _buildNewsList() {
    return Consumer<NewsProvider>(
      builder: (context, newsProvider, child) {
        // 加载中状态
        if (newsProvider.isLoading) {
          return const Center(child: CircularProgressIndicator());
        }

        // 错误状态
        if (newsProvider.error != null) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, size: 64, color: Colors.grey),
                const SizedBox(height: 16),
                Text('加载失败: ${newsProvider.error}'),
                const SizedBox(height: 16),
                ElevatedButton(
                  onPressed: () {
                    newsProvider.refreshNews(_selectedCategory);
                  },
                  child: const Text('重试'),
                ),
              ],
            ),
          );
        }

        // 获取数据
        final articles = newsProvider.getNewsByCategory(_selectedCategory);

        // 空数据状态
        if (articles.isEmpty) {
          return const Center(
            child: Text('暂无新闻'),
          );
        }

        // 正常显示列表
        return RefreshIndicator(
          onRefresh: () => newsProvider.refreshNews(_selectedCategory),
          child: ListView.builder(
            padding: const EdgeInsets.all(16),
            itemCount: articles.length,
            itemBuilder: (context, index) {
              return NewsCard(article: articles[index]);
            },
          ),
        );
      },
    );
  }
}

代码解析

  • Consumer<NewsProvider> - 监听数据变化,只重建这部分Widget,性能好
  • 加载中显示转圈动画,告诉用户正在加载数据
  • 加载失败显示错误信息和重试按钮,给用户解决方案
  • 空数据显示提示文字,友好的用户反馈
  • RefreshIndicator - 实现下拉刷新,这是移动应用的标准交互
  • ListView.builder - 按需构建列表项,只构建可见的Widget,性能好

为什么用Consumer而不是context.watch:Consumer的好处是只重建这一部分Widget,而context.watch会重建整个build方法。对于复杂页面,用Consumer性能更好。

数据缓存机制

NewsProvider实现了缓存机制,这是首页性能优化的关键:

// 在NewsProvider中
final Map<String, List<NewsArticle>> _newsCache = {};

Future<void> fetchNews(String category) async {
  // 如果缓存存在,直接返回
  if (_newsCache.containsKey(category) && _newsCache[category]!.isNotEmpty) {
    return;
  }
  
  // 加载数据...
  _newsCache[category] = articles;
}

Future<void> refreshNews(String category) async {
  _newsCache.remove(category);
  await fetchNews(category);
}

代码解析

  • 用Map存储不同分类的缓存数据,key是分类名称,value是新闻列表
  • 切换分类时先检查缓存,有则直接返回,不发起网络请求
  • 下拉刷新时清除缓存,重新加载,获取最新数据
  • 这大大提升了切换分类的速度,几乎是瞬间完成的

缓存的优缺点

优点:

  • 切换分类速度快
  • 减少网络请求
  • 节省流量

缺点:

  • 占用内存
  • 数据可能过期

对于新闻应用,这个权衡是值得的。用户经常在几个分类之间切换,缓存能大大提升体验。至于数据过期,用户可以下拉刷新获取最新数据。

性能优化

使用ListView.builder:只构建可见的Widget,滚动时动态创建和销毁,内存占用小

const构造函数:让Flutter复用Widget实例,不用每次都创建新的

避免在build中创建对象:分类列表定义为成员变量,只创建一次

合理使用setState:只在切换分类时调用,数据加载的状态变化由Provider管理

Consumer vs context.watch:Consumer只重建监听部分,性能更好

用户体验细节

加载状态反馈 - 显示转圈动画,用户知道正在加载
错误处理 - 显示错误信息和重试按钮,给用户解决方案
下拉刷新 - 标准交互,用户已习惯
数据缓存 - 切换分类几乎瞬间完成
空数据处理 - 友好的提示文字

这些细节看似简单,但决定了应用的质量。用户不会注意到这些细节做得好,但一定会注意到做得不好。

常见问题

initState里无法使用Provider

不能直接在initState里调用context.read<NewsProvider>(),会报错。解决办法是用addPostFrameCallback,在第一帧渲染完成后再访问Provider。

切换分类时列表闪烁

如果没有缓存机制,每次切换分类都要重新加载数据,会先显示加载指示器,然后显示新数据,视觉上有明显的闪烁。加了缓存后,切换分类直接显示缓存的数据,不会闪烁。

下拉刷新不生效

RefreshIndicator要求onRefresh必须返回Future,否则不知道什么时候刷新完成。我们的refreshNews方法本身就是async的,返回Future,所以可以直接传入。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐