Flutter 鸿蒙实战:图库应用的下拉刷新与上拉加载完整实现

深入解析基于 Flutter 鸿蒙适配版开发图库应用的完整流程,从零实现下拉刷新、上拉加载更多、状态管理等核心功能,涵盖常见问题排查与最佳实践。


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

📚 目录导航

章节 内容概要
一、项目背景与需求分析 开发背景、功能需求、技术选型
二、技术架构设计 分层架构、核心组件、数据流向
三、项目初始化与依赖配置 环境检查、项目创建、依赖管理
四、核心功能实现详解 API 封装、数据模型、状态管理
五、UI 组件开发 图片卡片、列表页面、状态提示
六、鸿蒙平台适配 权限配置、SDK 设置、网络配置
七、常见问题深度解析 状态冲突、刷新卡住、图片加载失败
八、性能优化与最佳实践 图片缓存、内存管理、滚动优化
九、测试验证方案 单元测试、集成测试、真机验证
十、项目部署与发布 版本管理、代码提交、远程仓库

一、项目背景与需求分析

1.1 开发背景

随着移动互联网的快速发展,图库类应用已成为移动开发中的常见场景。无论是社交应用、电商应用还是内容平台,都需要展示大量图片资源。在用户体验层面,下拉刷新上拉加载已成为图库类应用的标配功能:

  • 下拉刷新:用户主动获取最新内容,保证数据的时效性
  • 上拉加载:分页加载历史内容,避免一次性加载过多数据导致内存溢出

Flutter 作为成熟的跨平台框架,通过鸿蒙适配版本可以实现在 HarmonyOS 系统上运行。本项目旨在展示如何在 Flutter 鸿蒙开发中优雅地实现这两个核心功能。

1.2 功能需求详解

功能模块 具体需求 验收标准
数据加载 从网络 API 获取图片列表 支持分页,每页固定数量
下拉刷新 用户下拉时重新获取第一页数据 刷新完成后显示最新数据,状态正确更新
上拉加载 滚动到底部时自动加载下一页 无缝衔接,无重复数据
加载状态 显示加载中、加载失败、无更多数据等状态 用户清晰了解当前加载状态
错误处理 网络异常时显示错误提示并提供重试 支持点击重试,恢复加载
图片展示 网络图片加载,支持失败占位图 加载失败时显示友好提示
多平台兼容 同时支持 HarmonyOS 和 Android 双平台功能一致

1.3 技术选型对比

在选择下拉刷新和上拉加载的实现方案时,我们对主流方案进行了对比:

下拉刷新方案对比
方案 优点 缺点 推荐指数
pull_to_refresh 功能完善、可定制性强 需要手动管理状态 ⭐⭐⭐⭐⭐
RefreshIndicator Flutter 官方组件、开箱即用 定制能力有限 ⭐⭐⭐
easy_refresh 功能强大、支持多种动画 包体积较大 ⭐⭐⭐⭐

选择理由:本项目选用 pull_to_refresh,因为它提供了完善的刷新控制和状态管理,且与分页组件配合良好。

上拉分页方案对比
方案 优点 缺点 推荐指数
infinite_scroll_pagination 状态管理完善、API 清晰 学习曲线稍陡 ⭐⭐⭐⭐⭐
手动实现 ScrollController 完全可控、依赖少 需要处理边界条件 ⭐⭐⭐
waterfall_flow 支持瀑布流布局 功能相对单一 ⭐⭐⭐⭐

选择理由:本项目选用 infinite_scroll_pagination,它提供了完整的分页状态管理,可以处理加载中、加载失败、无更多数据等所有场景。

1.4 技术栈清单

技术组件 版本要求 用途说明
Flutter SDK 3.24+(需鸿蒙适配版) 跨平台 UI 框架
Dart 3.5+ 编程语言
pull_to_refresh ^2.0.0 下拉刷新组件
infinite_scroll_pagination ^4.0.0 分页加载管理
DevEco Studio 5.0.0+ HarmonyOS 开发 IDE
OpenHarmony SDK API 20+ 鸿蒙系统 SDK

二、技术架构设计

2.1 整体架构图

┌─────────────────────────────────────────────────────────────────┐
│                        Presentation Layer                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │  PhotoCard   │  │ PhotoListPage│  │  LoadingStatesWidget │  │
│  │  (Component) │  │   (Page)     │  │      (Component)     │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    State Management Layer                       │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │            PagingController + RefreshController            │ │
│  │  • _isRefreshing: 下拉刷新状态                              │ │
│  │  • _isLoadingMore: 上拉加载状态                             │ │
│  │  • PagingState: 分页数据状态                                │ │
│  └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                       Business Logic Layer                      │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │  PhotoApi    │  │  ApiConfig   │  │    ErrorHandler      │  │
│  │  (Service)   │  │   (Config)   │  │      (Utility)       │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                          Data Layer                             │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ PhotoModel   │  │  HttpClient  │  │   LocalCache         │  │
│  │  (Model)     │  │  (Network)   │  │      (Storage)       │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

2.2 核心组件职责

2.2.1 PagingController(分页控制器)

infinite_scroll_pagination 包提供的核心控制器,负责管理分页状态和数据。

核心属性:

// 分页控制器
final PagingController<int, PhotoModel> _pagingController =
    PagingController(firstPageKey: 1);

// 分页状态
_pagingController.value = PagingState(
  itemList: [],      // 当前页数据列表
  nextPageKey: 2,    // 下一页的页码(null 表示没有更多数据)
  error: null,       // 加载错误信息
);

核心方法:

// 添加分页数据(有下一页)
_pagingController.appendPage(data, nextPageKey);

// 添加最后一页数据
_pagingController.appendLastPage(data);

// 设置错误状态
_pagingController.error = exception;
2.2.2 RefreshController(刷新控制器)

pull_to_refresh 包提供的刷新控制器,负责管理下拉刷新的状态。

核心方法:

// 刷新完成
_refreshController.refreshCompleted();

// 刷新失败
_refreshController.refreshFailed();

// 加载更多完成
_refreshController.loadComplete();

// 加载更多失败,且没有更多数据
_refreshController.loadNoData();

2.3 状态管理策略

项目中采用了分离状态管理策略,这是解决下拉刷新和上拉加载冲突的关键:

// ❌ 错误做法:共用状态
bool _isLoading = false;  // 刷新和加载使用同一个状态

// ✅ 正确做法:分离状态
bool _isRefreshing = false;   // 下拉刷新专用状态
bool _isLoadingMore = false;  // 上拉加载专用状态

状态转换图:

                    ┌─────────────────┐
                    │   IDLE (空闲)    │
                    └────────┬────────┘
                             │
           ┌─────────────────┼─────────────────┐
           │                 │                 │
           ▼                 ▼                 ▼
    ┌─────────────┐   ┌─────────────┐   ┌─────────────┐
    │ REFRESHING  │   │ LOADING_MORE│   │    ERROR    │
    │ (刷新中)    │   │  (加载更多) │   │   (错误)    │
    └──────┬──────┘   └──────┬──────┘   └──────┬──────┘
           │                 │                 │
           └─────────────────┼─────────────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │   IDLE (空闲)    │
                    └─────────────────┘

2.4 数据流向图

用户操作 ──► 滚动事件 ──► PagingController ──► _fetchPage(pageKey)
                                                          │
                                                          ▼
                                                    PhotoApi.getList()
                                                          │
                                                          ▼
                                                    网络请求 (Picsum API)
                                                          │
                                                          ▼
                                                    解析响应数据
                                                          │
                                          ┌───────────────┴───────────────┐
                                          ▼                               ▼
                                  appendPage(data)               appendLastPage(data)
                                          │                               │
                                          └───────────────┬───────────────┘
                                                          ▼
                                                    PagedListView UI 更新

三、项目初始化与依赖配置

3.1 环境检查清单

在开始开发之前,请确保以下环境已正确配置:

# 1. 检查 Flutter 版本
flutter --version
# 期望输出: Flutter 3.24.x • channel stable

# 2. 检查支持的设备
flutter devices
# 期望输出: 包含 harmonyos 和 android 设备

# 3. 检查 Dart 版本
dart --version
# 期望输出: Dart 3.5.x

# 4. 运行环境诊断
flutter doctor -v

3.2 创建项目

# 1. 创建 Flutter 项目
flutter create photo_gallery_app

# 2. 进入项目目录
cd photo_gallery_app

# 3. 添加鸿蒙平台支持(关键步骤)
flutter create --platforms ohos .

# 4. 验证项目结构
tree -L 2 lib/

期望的项目结构:

lib/
├── main.dart
├── config/
├── models/
├── network/
├── pages/
├── widgets/
└── utils/

3.3 配置依赖

打开 pubspec.yaml,添加以下依赖:

name: photo_gallery_app
version: 1.0.0+1

environment:
  sdk: '>=3.5.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  # UI 组件
  cupertino_icons: ^1.0.8

  # 下拉刷新
  pull_to_refresh: ^2.0.0

  # 分页加载
  infinite_scroll_pagination: ^4.0.0

  # 状态管理(可选)
  provider: ^6.1.2

  # 网络请求
  dio: ^5.7.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^4.0.0

flutter:
  uses-material-design: true

安装依赖:

flutter pub get

3.4 项目目录结构设计

photo_gallery_app/
├── lib/
│   ├── main.dart                      # 应用入口
│   │
│   ├── config/                        # 配置文件
│   │   └── api_config.dart            # API 地址配置
│   │
│   ├── models/                        # 数据模型
│   │   └── photo_model.dart           # 图片数据模型
│   │
│   ├── network/                       # 网络层
│   │   ├── http_client.dart           # HTTP 客户端封装
│   │   └── photo_api.dart             # 图片 API 服务
│   │
│   ├── pages/                         # 页面
│   │   ├── home/                      # 首页
│   │   │   └── home_page.dart
│   │   └── gallery/                   # 图库页
│   │       └── gallery_page.dart      # 图片列表页(核心)
│   │
│   ├── widgets/                       # 可复用组件
│   │   ├── photo_card.dart            # 图片卡片
│   │   ├── empty_state.dart           # 空状态组件
│   │   └── loading_state.dart         # 加载状态组件
│   │
│   └── utils/                         # 工具类
│       ├── constants.dart             # 常量定义
│       └── logger.dart                # 日志工具
│
├── ohos/                              # 鸿蒙平台配置
│   └── entry/
│       └── src/
│           └── main/
│               ├── resources/
│               │   └── rawfile/
│               └── module.json5        # 权限配置(重点)
│
├── android/                           # Android 平台配置
├── ios/                               # iOS 平台配置(如需要)
└── pubspec.yaml                       # 依赖配置

四、核心功能实现详解

4.1 API 配置管理

文件:lib/config/api_config.dart

/// API 配置类
///
/// 集中管理所有 API 相关的配置信息,包括:
/// - 基础 URL
/// - 超时配置
/// - 分页参数
/// - 端点路径
class ApiConfig {
  // ==================== 基础配置 ====================

  /// Picsum Photos API - 免费图片服务
  /// 文档: https://picsum.photos/
  static const String imageBaseUrl = 'https://picsum.photos';

  /// 备用图片服务(可用于故障切换)
  static const String fallbackImageUrl = 'https://source.unsplash.com';

  // ==================== 网络配置 ====================

  /// 连接超时时间(秒)
  static const int connectTimeout = 15;

  /// 接收超时时间(秒)
  static const int receiveTimeout = 30;

  /// 图片加载超时时间(秒)
  static const int imageLoadTimeout = 10;

  // ==================== 分页配置 ====================

  /// 默认每页数据量
  static const int defaultPageSize = 20;

  /// 最小每页数据量
  static const int minPageSize = 5;

  /// 最大每页数据量
  static const int maxPageSize = 50;

  /// 预加载阈值(剩余多少项时触发预加载下一页)
  static const int preloadThreshold = 3;

  // ==================== 图片尺寸配置 ====================

  /// 默认图片宽度
  static const int defaultImageWidth = 800;

  /// 默认图片高度
  static const int defaultImageHeight = 600;

  /// 缩略图宽度
  static const int thumbnailWidth = 200;

  /// 缩略图高度
  static const int thumbnailHeight = 150;

  // ==================== 端点路径 ====================

  /// 图片列表端点(使用随机参数实现分页效果)
  static String getImageUrl({
    required int width,
    required int height,
    required int random,
  }) {
    return '$imageBaseUrl/$width/$height?random=$random';
  }

  /// 获取缩略图 URL
  static String getThumbnailUrl(int random) {
    return getImageUrl(
      width: thumbnailWidth,
      height: thumbnailHeight,
      random: random,
    );
  }

  /// 获取原图 URL
  static String getOriginalUrl(int random) {
    return getImageUrl(
      width: defaultImageWidth,
      height: defaultImageHeight,
      random: random,
    );
  }
}

4.2 数据模型定义

文件:lib/models/photo_model.dart

import 'dart:convert';

/// 图片数据模型
///
/// 包含图片的基本信息和计算属性
class PhotoModel {
  /// 唯一标识符
  final String id;

  /// 图片 URL
  final String url;

  /// 缩略图 URL
  final String? thumbnailUrl;

  /// 图片宽度(像素)
  final int width;

  /// 图片高度(像素)
  final int height;

  /// 图片描述
  final String? description;

  /// 作者信息
  final String? author;

  /// 创建时间
  final DateTime? createdAt;

  PhotoModel({
    required this.id,
    required this.url,
    this.thumbnailUrl,
    required this.width,
    required this.height,
    this.description,
    this.author,
    this.createdAt,
  });

  /// 从 JSON 创建对象
  factory PhotoModel.fromJson(Map<String, dynamic> json) {
    return PhotoModel(
      id: json['id']?.toString() ?? '',
      url: json['url']?.toString() ?? '',
      thumbnailUrl: json['thumbnailUrl']?.toString(),
      width: json['width'] as int? ?? json['w'] as int? ?? 0,
      height: json['height'] as int? ?? json['h'] as int? ?? 0,
      description: json['description']?.toString(),
      author: json['author']?.toString(),
      createdAt: json['createdAt'] != null
          ? DateTime.tryParse(json['createdAt'].toString())
          : null,
    );
  }

  /// 转换为 JSON
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'url': url,
      'thumbnailUrl': thumbnailUrl,
      'width': width,
      'height': height,
      'description': description,
      'author': author,
      'createdAt': createdAt?.toIso8601String(),
    };
  }

  /// 从 JSON 字符串创建
  static PhotoModel fromJsonString(String jsonString) {
    return PhotoModel.fromJson(jsonDecode(jsonString));
  }

  /// 转换为 JSON 字符串
  String toJsonString() {
    return jsonEncode(toJson());
  }

  // ==================== 计算属性 ====================

  /// 图片宽高比
  double get aspectRatio {
    return height > 0 ? width / height : 1.0;
  }

  /// 是否为横向图片(宽度 > 高度)
  bool get isLandscape => width > height;

  /// 是否为纵向图片(高度 > 宽度)
  bool get isPortrait => height > width;

  /// 是否为正方形图片
  bool get isSquare => width == height;

  /// 图片面积(像素)
  int get area => width * height;

  /// 图片尺寸描述
  String get sizeDescription {
    if (isSquare) return '正方形';
    if (isLandscape) return '横向';
    return '纵向';
  }

  /// 格式化的尺寸字符串
  String get formattedSize {
    if (width >= 1000 || height >= 1000) {
      return '${(width / 1000).toStringAsFixed(1)}k × ${(height / 1000).toStringAsFixed(1)}k';
    }
    return '${width} × $height}';
  }

  // ==================== 工厂方法 ====================

  /// 创建占位模型(用于加载状态)
  factory PhotoModel.placeholder() {
    return PhotoModel(
      id: 'placeholder_${DateTime.now().millisecondsSinceEpoch}',
      url: '',
      width: ApiConfig.defaultImageWidth,
      height: ApiConfig.defaultImageHeight,
    );
  }

  /// 创建测试数据
  static PhotoModel createTestPhoto(int index) {
    return PhotoModel(
      id: 'test_$index',
      url: ApiConfig.getOriginalUrl(index),
      thumbnailUrl: ApiConfig.getThumbnailUrl(index),
      width: ApiConfig.defaultImageWidth,
      height: ApiConfig.defaultImageHeight,
      description: '测试图片 $index',
      author: '测试作者',
      createdAt: DateTime.now(),
    );
  }

  /// 复制并修改部分属性
  PhotoModel copyWith({
    String? id,
    String? url,
    String? thumbnailUrl,
    int? width,
    int? height,
    String? description,
    String? author,
    DateTime? createdAt,
  }) {
    return PhotoModel(
      id: id ?? this.id,
      url: url ?? this.url,
      thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl,
      width: width ?? this.width,
      height: height ?? this.height,
      description: description ?? this.description,
      author: author ?? this.author,
      createdAt: createdAt ?? this.createdAt,
    );
  }

  
  String toString() {
    return 'PhotoModel(id: $id, size: $formattedSize, ratio: ${aspectRatio.toStringAsFixed(2)})';
  }

  
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is PhotoModel && other.id == id;
  }

  
  int get hashCode => id.hashCode;
}

4.3 API 服务层

文件:lib/network/photo_api.dart

import '../config/api_config.dart';
import '../models/photo_model.dart';
import '../utils/logger.dart';

/// 图片 API 服务类
///
/// 负责与图片服务端进行交互,提供分页获取图片的功能
class PhotoApi {
  static const String _tag = 'PhotoApi';

  /// 获取图片列表(支持分页)
  ///
  /// [page] 页码,从 1 开始
  /// [pageSize] 每页数量,默认 20,范围 [5, 50]
  ///
  /// 返回图片列表,失败时抛出异常
  static Future<List<PhotoModel>> getPhotoList({
    required int page,
    int pageSize = ApiConfig.defaultPageSize,
  }) async {
    // 参数验证
    if (page < 1) {
      throw ArgumentError('页码必须大于等于 1');
    }
    if (pageSize < ApiConfig.minPageSize || pageSize > ApiConfig.maxPageSize) {
      throw ArgumentError('每页数量必须在 ${ApiConfig.minPageSize}${ApiConfig.maxPageSize} 之间');
    }

    try {
      AppLogger.info('$_tag: 开始加载第 $page 页数据,每页 $pageSize 项');

      // 模拟网络延迟(可选,用于演示加载状态)
      await Future.delayed(const Duration(milliseconds: 500));

      // 计算随机数偏移量,确保每页数据不同
      final offset = (page - 1) * pageSize;

      // 生成图片列表
      final photos = List.generate(pageSize, (index) {
        final randomNum = offset + index + 1;
        return PhotoModel(
          id: 'photo_$randomNum',
          url: ApiConfig.getOriginalUrl(randomNum),
          thumbnailUrl: ApiConfig.getThumbnailUrl(randomNum),
          width: ApiConfig.defaultImageWidth,
          height: ApiConfig.defaultImageHeight,
          description: '风景图片 #$randomNum',
          author: 'Picsum Photos',
          createdAt: DateTime.now(),
        );
      });

      AppLogger.info('$_tag: 第 $page 页数据加载成功,共 ${photos.length} 项');

      return photos;
    } catch (e, stackTrace) {
      AppLogger.error('$_tag: 加载失败', error: e, stackTrace: stackTrace);
      rethrow;
    }
  }

  /// 获取单张随机图片
  static Future<PhotoModel> getRandomPhoto() async {
    final random = DateTime.now().millisecondsSinceEpoch;
    return PhotoModel(
      id: 'random_$random',
      url: ApiConfig.getOriginalUrl(random),
      thumbnailUrl: ApiConfig.getThumbnailUrl(random),
      width: ApiConfig.defaultImageWidth,
      height: ApiConfig.defaultImageHeight,
      description: '随机图片',
      author: 'Picsum Photos',
      createdAt: DateTime.now(),
    );
  }

  /// 根据关键词搜索图片(模拟实现)
  static Future<List<PhotoModel>> searchPhotos({
    required String keyword,
    int page = 1,
    int pageSize = ApiConfig.defaultPageSize,
  }) async {
    AppLogger.info('$_tag: 搜索关键词: $keyword, 第 $page 页');

    // 模拟搜索延迟
    await Future.delayed(const Duration(milliseconds: 800));

    // 使用关键词生成确定性的随机数
    final keywordHash = keyword.hashCode.abs();
    final offset = (page - 1) * pageSize;

    return List.generate(pageSize, (index) {
      final randomNum = keywordHash + offset + index;
      return PhotoModel(
        id: 'search_${keyword}_$randomNum',
        url: ApiConfig.getOriginalUrl(randomNum),
        thumbnailUrl: ApiConfig.getThumbnailUrl(randomNum),
        width: ApiConfig.defaultImageWidth,
        height: ApiConfig.defaultImageHeight,
        description: '$keyword 相关图片 #${index + 1}',
        author: 'Picsum Photos',
        createdAt: DateTime.now(),
      );
    });
  }

  /// 获取推荐图片(模拟实现)
  static Future<List<PhotoModel>> getRecommendedPhotos({
    int limit = 10,
  }) async {
    AppLogger.info('$_tag: 获取推荐图片,数量: $limit');

    await Future.delayed(const Duration(milliseconds: 300));

    final now = DateTime.now();
    return List.generate(limit, (index) {
      final randomNum = now.millisecondsSinceEpoch + index;
      return PhotoModel(
        id: 'recommend_$randomNum',
        url: ApiConfig.getOriginalUrl(randomNum),
        thumbnailUrl: ApiConfig.getThumbnailUrl(randomNum),
        width: ApiConfig.defaultImageWidth,
        height: ApiConfig.defaultImageHeight,
        description: '推荐图片 #${index + 1}',
        author: 'Picsum Photos',
        createdAt: now,
      );
    });
  }
}

文件:lib/utils/logger.dart

import 'dart:developer' as developer;

/// 简单的日志工具类
class AppLogger {
  /// 日志级别
  static bool _isDebug = true;

  /// 设置是否为调试模式
  static void setIsDebug(bool isDebug) {
    _isDebug = isDebug;
  }

  /// 输出调试日志
  static void debug(String message, {Object? error, StackTrace? stackTrace}) {
    if (_isDebug) {
      developer.log(
        message,
        name: 'DEBUG',
        error: error,
        stackTrace: stackTrace,
      );
    }
  }

  /// 输出信息日志
  static void info(String message, {Object? error, StackTrace? stackTrace}) {
    developer.log(
      message,
      name: 'INFO',
      error: error,
      stackTrace: stackTrace,
    );
  }

  /// 输出警告日志
  static void warning(String message, {Object? error, StackTrace? stackTrace}) {
    developer.log(
      message,
      name: 'WARNING',
      error: error,
      stackTrace: stackTrace,
    );
  }

  /// 输出错误日志
  static void error(String message, {Object? error, StackTrace? stackTrace}) {
    developer.log(
      message,
      name: 'ERROR',
      error: error,
      stackTrace: stackTrace,
      level: 1000, // 错误级别
    );
  }
}

五、UI 组件开发

5.1 图片卡片组件

文件:lib/widgets/photo_card.dart

import 'package:flutter/material.dart';
import '../models/photo_model.dart';

/// 图片卡片组件
///
/// 展示单张图片的卡片,包含:
/// - 网络图片加载
/// - 加载进度显示
/// - 加载失败占位
/// - 图片信息展示
class PhotoCard extends StatelessWidget {
  /// 图片数据
  final PhotoModel photo;

  /// 点击回调
  final VoidCallback? onTap;

  /// 长按回调
  final VoidCallback? onLongPress;

  /// 是否显示详细信息
  final bool showDetails;

  /// 卡片圆角
  final double borderRadius;

  const PhotoCard({
    super.key,
    required this.photo,
    this.onTap,
    this.onLongPress,
    this.showDetails = true,
    this.borderRadius = 12,
  });

  
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(borderRadius),
      ),
      elevation: 2,
      clipBehavior: Clip.antiAlias,
      child: InkWell(
        onTap: onTap,
        onLongPress: onLongPress,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _buildImage(context),
            if (showDetails) _buildInfo(context),
          ],
        ),
      ),
    );
  }

  /// 构建图片区域
  Widget _buildImage(BuildContext context) {
    return AspectRatio(
      aspectRatio: photo.aspectRatio,
      child: Image.network(
        photo.url,
        fit: BoxFit.cover,
        // 加载中显示进度
        loadingBuilder: (context, child, loadingProgress) {
          if (loadingProgress == null) return child;

          final progress = loadingProgress.expectedTotalBytes != null
              ? loadingProgress.cumulativeBytesLoaded /
                loadingProgress.expectedTotalBytes!
              : null;

          return Container(
            color: Colors.grey[100],
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  CircularProgressIndicator(
                    value: progress,
                    backgroundColor: Colors.grey[300],
                  ),
                  if (progress != null) ...[
                    const SizedBox(height: 16),
                    Text(
                      '${(progress * 100).toInt()}%',
                      style: TextStyle(
                        color: Colors.grey[600],
                        fontSize: 12,
                      ),
                    ),
                  ],
                ],
              ),
            ),
          );
        },
        // 加载失败显示占位图
        errorBuilder: (context, error, stackTrace) {
          return Container(
            color: Colors.grey[200],
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(
                    Icons.broken_image,
                    size: 48,
                    color: Colors.grey[400],
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '图片加载失败',
                    style: TextStyle(
                      color: Colors.grey[600],
                      fontSize: 12,
                    ),
                  ),
                  const SizedBox(height: 4),
                  Text(
                    'ID: ${photo.id}',
                    style: TextStyle(
                      color: Colors.grey[500],
                      fontSize: 10,
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }

  /// 构建信息区域
  Widget _buildInfo(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 尺寸信息
          Text(
            photo.formattedSize,
            style: Theme.of(context).textTheme.titleSmall?.copyWith(
                  fontWeight: FontWeight.w600,
                ),
          ),
          const SizedBox(height: 4),
          // 描述信息
          if (photo.description != null) ...[
            Text(
              photo.description!,
              style: Theme.of(context).textTheme.bodySmall?.copyWith(
                    color: Colors.grey[700],
                  ),
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
            const SizedBox(height: 8),
          ],
          // 标签区域
          Wrap(
            spacing: 8,
            runSpacing: 4,
            children: [
              _buildTag(context, photo.sizeDescription, _getTagColor(photo)),
              if (photo.isLandscape)
                _buildTag(context, '16:9', Colors.blue),
              else if (photo.isPortrait)
                _buildTag(context, '9:16', Colors.green),
              else
                _buildTag(context, '1:1', Colors.orange),
            ],
          ),
        ],
      ),
    );
  }

  /// 构建标签
  Widget _buildTag(BuildContext context, String label, Color color) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: color.withOpacity(0.1),
        borderRadius: BorderRadius.circular(4),
        border: Border.all(color: color, width: 1),
      ),
      child: Text(
        label,
        style: TextStyle(
          color: color,
          fontSize: 11,
          fontWeight: FontWeight.w500,
        ),
      ),
    );
  }

  /// 获取标签颜色
  Color _getTagColor(PhotoModel photo) {
    if (photo.isLandscape) return Colors.blue;
    if (photo.isPortrait) return Colors.green;
    return Colors.orange;
  }
}

/// 带动画效果的图片卡片
class AnimatedPhotoCard extends StatefulWidget {
  final PhotoModel photo;
  final VoidCallback? onTap;
  final VoidCallback? onLongPress;
  final bool showDetails;

  const AnimatedPhotoCard({
    super.key,
    required this.photo,
    this.onTap,
    this.onLongPress,
    this.showDetails = true,
  });

  
  State<AnimatedPhotoCard> createState() => _AnimatedPhotoCardState();
}

class _AnimatedPhotoCardState extends State<AnimatedPhotoCard>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;
  late Animation<double> _opacityAnimation;

  
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 300),
      vsync: this,
    );

    _scaleAnimation = Tween<double>(begin: 0.95, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );

    _opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeOut),
    );

    _controller.forward();
  }

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scaleAnimation,
      child: FadeTransition(
        opacity: _opacityAnimation,
        child: PhotoCard(
          photo: widget.photo,
          onTap: widget.onTap,
          onLongPress: widget.onLongPress,
          showDetails: widget.showDetails,
        ),
      ),
    );
  }
}

5.2 加载状态组件

文件:lib/widgets/loading_state.dart

import 'package:flutter/material.dart';

/// 加载状态枚举
enum LoadingStatus {
  /// 初始状态
  initial,

  /// 加载中
  loading,

  /// 加载成功
  success,

  /// 加载失败
  error,

  /// 空数据
  empty,
}

/// 通用加载状态组件
class LoadingStateWidget extends StatelessWidget {
  /// 当前状态
  final LoadingStatus status;

  /// 错误信息
  final String? errorMessage;

  /// 空状态提示
  final String? emptyMessage;

  /// 重试回调
  final VoidCallback? onRetry;

  /// 成功时显示的内容
  final Widget? child;

  const LoadingStateWidget({
    super.key,
    required this.status,
    this.errorMessage,
    this.emptyMessage,
    this.onRetry,
    this.child,
  });

  
  Widget build(BuildContext context) {
    switch (status) {
      case LoadingStatus.loading:
        return _buildLoading(context);

      case LoadingStatus.error:
        return _buildError(context);

      case LoadingStatus.empty:
        return _buildEmpty(context);

      case LoadingStatus.success:
        return child ?? const SizedBox.shrink();

      case LoadingStatus.initial:
        return const SizedBox.shrink();
    }
  }

  /// 构建加载中状态
  Widget _buildLoading(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const CircularProgressIndicator(),
          const SizedBox(height: 16),
          Text(
            '加载中...',
            style: TextStyle(
              color: Colors.grey[600],
              fontSize: 14,
            ),
          ),
        ],
      ),
    );
  }

  /// 构建错误状态
  Widget _buildError(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.error_outline,
              size: 64,
              color: Colors.red[300],
            ),
            const SizedBox(height: 16),
            Text(
              errorMessage ?? '加载失败',
              style: TextStyle(
                fontSize: 16,
                color: Colors.grey[800],
              ),
              textAlign: TextAlign.center,
            ),
            if (onRetry != null) ...[
              const SizedBox(height: 24),
              FilledButton.icon(
                onPressed: onRetry,
                icon: const Icon(Icons.refresh),
                label: const Text('重试'),
              ),
            ],
          ],
        ),
      ),
    );
  }

  /// 构建空状态
  Widget _buildEmpty(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.photo_library_outlined,
              size: 64,
              color: Colors.grey[400],
            ),
            const SizedBox(height: 16),
            Text(
              emptyMessage ?? '暂无图片',
              style: TextStyle(
                fontSize: 16,
                color: Colors.grey[600],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// 列表底部加载更多指示器
class LoadMoreIndicator extends StatelessWidget {
  /// 是否正在加载
  final bool isLoading;

  /// 是否没有更多数据
  final bool hasMore;

  /// 加载失败时的重试回调
  final VoidCallback? onRetry;

  const LoadMoreIndicator({
    super.key,
    required this.isLoading,
    required this.hasMore,
    this.onRetry,
  });

  
  Widget build(BuildContext context) {
    if (!hasMore) {
      return _buildNoMoreIndicator();
    }

    if (isLoading) {
      return _buildLoadingIndicator();
    }

    if (onRetry != null) {
      return _buildErrorIndicator();
    }

    return const SizedBox.shrink();
  }

  /// 构建"没有更多数据"指示器
  Widget _buildNoMoreIndicator() {
    return const Center(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Expanded(child: Divider(height: 1)),
            Padding(
              padding: EdgeInsets.symmetric(horizontal: 16),
              child: Text(
                '没有更多数据了',
                style: TextStyle(
                  color: Colors.grey,
                  fontSize: 12,
                ),
              ),
            ),
            Expanded(child: Divider(height: 1)),
          ],
        ),
      ),
    );
  }

  /// 构建加载中指示器
  Widget _buildLoadingIndicator() {
    return const Center(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: CircularProgressIndicator(),
      ),
    );
  }

  /// 构建错误指示器
  Widget _buildErrorIndicator() {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            Text(
              '加载失败',
              style: TextStyle(
                color: Colors.grey[600],
                fontSize: 12,
              ),
            ),
            const SizedBox(height: 8),
            TextButton(
              onPressed: onRetry,
              child: const Text('点击重试'),
            ),
          ],
        ),
      ),
    );
  }
}

/// 下拉刷新头部指示器(自定义)
class CustomRefreshHeader extends StatelessWidget {
  final RefreshStatus status;

  const CustomRefreshHeader({super.key, required this.status});

  
  Widget build(BuildContext context) {
    String text;
    IconData icon;

    switch (status) {
      case RefreshStatus.idle:
        text = '下拉刷新';
        icon = Icons.arrow_downward;
        break;
      case RefreshStatus.canRefresh:
        text = '释放立即刷新';
        icon = Icons.refresh;
        break;
      case RefreshStatus.refreshing:
        text = '正在刷新...';
        icon = Icons.autorenew;
        break;
      case RefreshStatus.completed:
        text = '刷新完成';
        icon = Icons.done;
        break;
      case RefreshStatus.failed:
        text = '刷新失败';
        icon = Icons.error;
        break;
      default:
        text = '下拉刷新';
        icon = Icons.arrow_downward;
    }

    return Container(
      height: 60,
      alignment: Alignment.center,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          if (status == RefreshStatus.refreshing)
            const SizedBox(
              width: 20,
              height: 20,
              child: CircularProgressIndicator(strokeWidth: 2),
            )
          else
            Icon(icon, size: 20, color: Colors.grey[600]),
          const SizedBox(width: 12),
          Text(
            text,
            style: TextStyle(
              color: Colors.grey[600],
              fontSize: 14,
            ),
          ),
        ],
      ),
    );
  }
}

5.3 图库列表页面(核心)

文件:lib/pages/gallery/gallery_page.dart

import 'package:flutter/material.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../../models/photo_model.dart';
import '../../network/photo_api.dart';
import '../../widgets/photo_card.dart';
import '../../widgets/loading_state.dart';

/// 图库列表页面
///
/// 核心功能:
/// - 下拉刷新第一页数据
/// - 上拉加载更多分页数据
/// - 状态管理和错误处理
class GalleryPage extends StatefulWidget {
  const GalleryPage({super.key});

  
  State<GalleryPage> createState() => _GalleryPageState();
}

class _GalleryPageState extends State<GalleryPage> {
  // ==================== 控制器 ====================

  /// 下拉刷新控制器
  final RefreshController _refreshController = RefreshController();

  /// 分页控制器
  final PagingController<int, PhotoModel> _pagingController =
      PagingController(firstPageKey: 1);

  // ==================== 配置参数 ====================

  /// 每页数据量
  final int _pageSize = 20;

  // ==================== 状态变量 ====================

  /// 下拉刷新状态(独立于加载状态,避免冲突)
  bool _isRefreshing = false;

  /// 上拉加载状态
  bool _isLoadingMore = false;

  /// 是否有错误
  bool _hasError = false;

  
  void initState() {
    super.initState();
    // 绑定分页请求监听器
    _pagingController.addPageRequestListener(_fetchPage);
    // 加载初始数据
    _loadFirstPage();
  }

  
  void dispose() {
    _refreshController.dispose();
    _pagingController.dispose();
    super.dispose();
  }

  // ==================== 数据加载 ====================

  /// 加载第一页数据
  Future<void> _loadFirstPage() async {
    try {
      AppLogger.info('GalleryPage: 开始加载第一页数据');

      final photos = await PhotoApi.getPhotoList(
        page: 1,
        pageSize: _pageSize,
      );

      if (!mounted) return;

      // 判断是否为最后一页
      final isLastPage = photos.isEmpty || photos.length < _pageSize;

      if (isLastPage) {
        _pagingController.appendLastPage(photos);
        AppLogger.info('GalleryPage: 第一页也是最后一页');
      } else {
        _pagingController.appendPage(photos, 2);
        AppLogger.info('GalleryPage: 第一页加载成功,共 ${photos.length} 项');
      }

      _hasError = false;
    } catch (e, stackTrace) {
      AppLogger.error('GalleryPage: 第一页加载失败', error: e, stackTrace: stackTrace);

      if (mounted) {
        _pagingController.error = e;
        _hasError = true;
      }
    }
  }

  /// 分页加载数据
  ///
  /// [pageKey] 页码
  Future<void> _fetchPage(int pageKey) async {
    // 防止与下拉刷新冲突
    if (_isRefreshing) {
      AppLogger.info('GalleryPage: 正在刷新,跳过第 $pageKey 页加载');
      return;
    }

    // 防止重复加载
    if (_isLoadingMore) {
      AppLogger.info('GalleryPage: 正在加载,跳过第 $pageKey 页请求');
      return;
    }

    _isLoadingMore = true;

    try {
      AppLogger.info('GalleryPage: 开始加载第 $pageKey 页数据');

      final photos = await PhotoApi.getPhotoList(
        page: pageKey,
        pageSize: _pageSize,
      );

      if (!mounted) return;

      // 判断是否为最后一页
      final isLastPage = photos.isEmpty || photos.length < _pageSize;

      if (isLastPage) {
        _pagingController.appendLastPage(photos);
        AppLogger.info('GalleryPage: 第 $pageKey 页是最后一页');
      } else {
        _pagingController.appendPage(photos, pageKey + 1);
        AppLogger.info('GalleryPage: 第 $pageKey 页加载成功,共 ${photos.length} 项');
      }

      _hasError = false;
    } catch (e, stackTrace) {
      AppLogger.error('GalleryPage: 第 $pageKey 页加载失败', error: e, stackTrace: stackTrace);

      if (mounted) {
        _pagingController.error = e;
        _hasError = true;
      }
    } finally {
      _isLoadingMore = false;
    }
  }

  /// 下拉刷新
  Future<void> _onRefresh() async {
    // 防止与上拉加载冲突
    if (_isLoadingMore) {
      AppLogger.info('GalleryPage: 正在加载更多,取消刷新');
      _refreshController.refreshCompleted();
      return;
    }

    // 防止重复刷新
    if (_isRefreshing) {
      AppLogger.info('GalleryPage: 正在刷新,跳过本次请求');
      _refreshController.refreshCompleted();
      return;
    }

    _isRefreshing = true;

    try {
      AppLogger.info('GalleryPage: 开始下拉刷新');

      final photos = await PhotoApi.getPhotoList(
        page: 1,
        pageSize: _pageSize,
      );

      if (!mounted) return;

      // 判断是否为最后一页
      final isLastPage = photos.isEmpty || photos.length < _pageSize;

      // 直接更新 PagingController 的状态
      _pagingController.value = PagingState(
        itemList: photos,
        nextPageKey: isLastPage ? null : 2,
      );

      AppLogger.info('GalleryPage: 刷新成功,共 ${photos.length} 项');

      _refreshController.refreshCompleted();

      // 显示成功提示
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('刷新成功,已更新 ${photos.length} 张图片'),
            duration: const Duration(seconds: 1),
            behavior: SnackBarBehavior.floating,
          ),
        );
      }

      _hasError = false;
    } catch (e, stackTrace) {
      AppLogger.error('GalleryPage: 刷新失败', error: e, stackTrace: stackTrace);

      _refreshController.refreshFailed();

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('刷新失败:$e'),
            duration: const Duration(seconds: 2),
            behavior: SnackBarBehavior.floating,
            action: SnackBarAction(
              label: '重试',
              onPressed: _onRefresh,
            ),
          ),
        );
      }

      _hasError = true;
    } finally {
      _isRefreshing = false;
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: _buildAppBar(),
      body: _buildBody(),
    );
  }

  /// 构建应用栏
  PreferredSizeWidget _buildAppBar() {
    return AppBar(
      title: const Text('风景图库'),
      centerTitle: true,
      actions: [
        // 刷新按钮
        IconButton(
          icon: const Icon(Icons.refresh),
          tooltip: '刷新',
          onPressed: _isRefreshing || _isLoadingMore
              ? null
              : _onRefresh,
        ),
        // 设置菜单
        PopupMenuButton<String>(
          onSelected: (value) {
            switch (value) {
              case 'refresh':
                _onRefresh();
                break;
              case 'clear':
                _pagingController.refresh();
                _loadFirstPage();
                break;
            }
          },
          itemBuilder: (context) => [
            const PopupMenuItem(
              value: 'refresh',
              child: Row(
                children: [
                  Icon(Icons.refresh),
                  SizedBox(width: 12),
                  Text('刷新'),
                ],
              ),
            ),
            const PopupMenuItem(
              value: 'clear',
              child: Row(
                children: [
                  Icon(Icons.clear_all),
                  SizedBox(width: 12),
                  Text('清空缓存'),
                ],
              ),
            ),
          ],
        ),
      ],
    );
  }

  /// 构建页面主体
  Widget _buildBody() {
    return SmartRefresher(
      controller: _refreshController,
      onRefresh: _onRefresh,
      header: const ClassicHeader(
        refreshText: '下拉刷新',
        refreshStyle: RefreshStyle.Behind,
        idleText: '下拉刷新',
        releaseText: '释放立即刷新',
        completeText: '刷新完成',
        refreshingText: '正在刷新...',
        failedText: '刷新失败',
      ),
      child: PagedListView<int, PhotoModel>(
        pagingController: _pagingController,
        builderDelegate: PagedChildBuilderDelegate<PhotoModel>(
          // 列表项构建器
          itemBuilder: (context, photo, index) {
            return AnimatedPhotoCard(
              photo: photo,
              onTap: () => _openPhotoDetail(photo, index),
            );
          },

          // 首页加载中指示器
          firstPageProgressIndicatorBuilder: (_) => const Center(
            child: Padding(
              padding: EdgeInsets.all(32),
              child: CircularProgressIndicator(),
            ),
          ),

          // 首页错误指示器
          firstPageErrorIndicatorBuilder: (_) => Center(
            child: Padding(
              padding: const EdgeInsets.all(32),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.error_outline, size: 64, color: Colors.red[300]),
                  const SizedBox(height: 16),
                  const Text(
                    '加载失败',
                    style: TextStyle(fontSize: 16),
                  ),
                  const SizedBox(height: 8),
                  const Text(
                    '请检查网络连接后重试',
                    style: TextStyle(fontSize: 12, color: Colors.grey),
                  ),
                  const SizedBox(height: 24),
                  FilledButton.icon(
                    onPressed: _loadFirstPage,
                    icon: const Icon(Icons.refresh),
                    label: const Text('重新加载'),
                  ),
                ],
              ),
            ),
          ),

          // 首页空状态指示器
          firstPageEmptyIndicatorBuilder: (_) => Center(
            child: Padding(
              padding: const EdgeInsets.all(32),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.photo_library_outlined,
                       size: 64,
                       color: Colors.grey[400]),
                  const SizedBox(height: 16),
                  Text(
                    '暂无图片',
                    style: TextStyle(fontSize: 16, color: Colors.grey[600]),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '下拉刷新获取最新内容',
                    style: TextStyle(fontSize: 12, color: Colors.grey[500]),
                  ),
                ],
              ),
            ),
          ),

          // 新页加载中指示器
          newPageProgressIndicatorBuilder: (_) => const Center(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: CircularProgressIndicator(),
            ),
          ),

          // 新页错误指示器
          newPageErrorIndicatorBuilder: (_) => Center(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                children: [
                  Text('加载失败', style: TextStyle(color: Colors.grey[600])),
                  const SizedBox(height: 8),
                  TextButton(
                    onPressed: () => _pagingController.retryLastFailedRequest(),
                    child: const Text('点击重试'),
                  ),
                ],
              ),
            ),
          ),

          // 没有更多数据指示器
          noMoreItemsIndicatorBuilder: (_) => const Center(
            child: Padding(
              padding: EdgeInsets.all(16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Expanded(child: Divider(height: 1)),
                  Padding(
                    padding: EdgeInsets.symmetric(horizontal: 16),
                    child: Text(
                      '没有更多数据了',
                      style: TextStyle(color: Colors.grey, fontSize: 12),
                    ),
                  ),
                  Expanded(child: Divider(height: 1)),
                ],
              ),
            ),
          ),

          // 列表之间的间距
          animateTransitions: true,
          transitionDuration: const Duration(milliseconds: 300),
        ),
      ),
    );
  }

  /// 打开图片详情页
  void _openPhotoDetail(PhotoModel photo, int index) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => _PhotoDetailPage(
          photo: photo,
          heroTag: 'photo_$index',
        ),
      ),
    );
  }
}

/// 图片详情页
class _PhotoDetailPage extends StatelessWidget {
  final PhotoModel photo;
  final String heroTag;

  const _PhotoDetailPage({
    required this.photo,
    required this.heroTag,
  });

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('图片详情'),
        actions: [
          IconButton(
            icon: const Icon(Icons.share),
            onPressed: () {
              // TODO: 实现分享功能
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('分享功能开发中')),
              );
            },
          ),
          IconButton(
            icon: const Icon(Icons.download),
            onPressed: () {
              // TODO: 实现下载功能
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(content: Text('下载功能开发中')),
              );
            },
          ),
        ],
      ),
      body: SingleChildScrollView(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Hero 动画图片
            Hero(
              tag: heroTag,
              child: Image.network(
                photo.url,
                width: double.infinity,
                fit: BoxFit.contain,
                loadingBuilder: (context, child, loadingProgress) {
                  if (loadingProgress == null) return child;
                  return Container(
                    height: 300,
                    color: Colors.grey[100],
                    child: const Center(
                      child: CircularProgressIndicator(),
                    ),
                  );
                },
              ),
            ),
            // 详情信息
            Padding(
              padding: const EdgeInsets.all(20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    photo.description ?? '风景图片',
                    style: const TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    '作者: ${photo.author ?? "未知"}',
                    style: TextStyle(
                      color: Colors.grey[600],
                      fontSize: 14,
                    ),
                  ),
                  const SizedBox(height: 20),
                  _buildInfoRow('图片 ID', photo.id),
                  _buildInfoRow('尺寸', '${photo.width} × ${photo.height} 像素'),
                  _buildInfoRow('宽高比', photo.aspectRatio.toStringAsFixed(3)),
                  _buildInfoRow('方向', photo.sizeDescription),
                  _buildInfoRow('面积', '${(photo.area / 1000000).toStringAsFixed(2)} MP'),
                  const SizedBox(height: 20),
                  const Text(
                    '图片链接',
                    style: TextStyle(
                      fontSize: 16,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  SelectableText(
                    photo.url,
                    style: TextStyle(
                      color: Colors.blue[600],
                      fontSize: 12,
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 80,
            child: Text(
              '$label:',
              style: const TextStyle(fontWeight: FontWeight.w500),
            ),
          ),
          Expanded(child: Text(value)),
        ],
      ),
    );
  }
}

六、鸿蒙平台适配

6.1 网络权限配置

在鸿蒙平台上访问网络需要配置相应的权限。编辑 ohos/entry/src/main/module.json5

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:internet_permission_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.GET_NETWORK_INFO",
        "reason": "$string:network_info_permission_reason",
        "usedScene": {
          "abilities": [
            "EntryAbility"
          ],
          "when": "inuse"
        }
      }
    ]
  }
}

同时在 ohos/entry/src/main/resources/base/element/string.json 中添加权限说明:

{
  "string": [
    {
      "name": "internet_permission_reason",
      "value": "应用需要访问网络以加载图片内容"
    },
    {
      "name": "network_info_permission_reason",
      "value": "应用需要获取网络状态以提供更好的体验"
    }
  ]
}

6.2 SDK 版本配置

编辑 ohos/build-profile.json5,确保配置正确的 SDK 版本:

{
  "app": {
    "products": [
      {
        "name": "default",
        "compatibleSdkVersion": "5.0.0(12)",
        "targetSdkVersion": "5.0.0(12)",
        "runtimeOS": "HarmonyOS"
      }
    ]
  }
}

6.3 网络安全配置

如果需要支持 HTTP 请求(不推荐生产环境使用),需要配置网络安全策略:

创建 ohos/entry/src/main/resources/rawfile/network_config.json

{
  "networkSecurityConfig": {
    "cleartextTrafficPermitted": true,
    "domains": [
      {
        "domain": "picsum.photos",
        "subdomains": true
      }
    ]
  }
}

七、常见问题深度解析

7.1 问题一:下拉刷新一次后卡住,无法再次刷新

现象描述
  • 第一次下拉刷新正常工作
  • 之后无法触发刷新,一直显示"刷新中"状态
  • 控制台无错误提示
根本原因

下拉刷新和上拉加载使用了同一个状态变量 _isLoading,导致状态冲突:

// ❌ 问题代码
bool _isLoading = false;

Future<void> _onRefresh() async {
  if (_isLoading) return;  // 如果正在加载,直接返回
  _isLoading = true;
  try {
    // 刷新逻辑
  } finally {
    _isLoading = false;  // 这里可能不会执行到
  }
}

Future<void> _fetchPage(int pageKey) async {
  if (_isLoading) return;  // 刷新时会阻止
  _isLoading = true;
  try {
    // 加载逻辑
  } finally {
    _isLoading = false;
  }
}

冲突场景:

  1. 用户上拉加载,触发 _fetchPage_isLoading = true
  2. 用户立即下拉刷新,被 _isLoading 检查阻止
  3. 上拉加载完成后 _isLoading = false,但刷新已经不会再次触发
解决方案

分离状态变量,让刷新和加载各自管理自己的状态:

// ✅ 正确代码
bool _isRefreshing = false;   // 下拉刷新专用状态
bool _isLoadingMore = false;  // 上拉加载专用状态

Future<void> _onRefresh() async {
  // 检查刷新状态(不检查加载状态)
  if (_isRefreshing) return;

  _isRefreshing = true;
  try {
    final data = await PhotoApi.getPhotoList(page: 1, pageSize: _pageSize);
    _pagingController.value = PagingState(
      itemList: data,
      nextPageKey: data.length < _pageSize ? null : 2,
    );
    _refreshController.refreshCompleted();
  } catch (e) {
    _refreshController.refreshFailed();
    rethrow;
  } finally {
    _isRefreshing = false;  // 确保状态重置
  }
}

Future<void> _fetchPage(int pageKey) async {
  // 只检查刷新状态,避免冲突
  if (_isRefreshing) return;

  _isLoadingMore = true;
  try {
    final data = await PhotoApi.getPhotoList(page: pageKey, pageSize: _pageSize);
    final isLastPage = data.isEmpty || data.length < _pageSize;
    if (isLastPage) {
      _pagingController.appendLastPage(data);
    } else {
      _pagingController.appendPage(data, pageKey + 1);
    }
  } catch (e) {
    _pagingController.error = e;
  } finally {
    _isLoadingMore = false;  // 确保状态重置
  }
}

状态管理最佳实践:

场景 检查条件 设置状态 重置位置
下拉刷新 if (_isRefreshing) return _isRefreshing = true finally { _isRefreshing = false; }
上拉加载 if (_isRefreshing || _isLoadingMore) return _isLoadingMore = true finally { _isLoadingMore = false; }

7.2 问题二:刷新状态 refreshCompleted() 未生效

现象描述
  • 刷新完成后,下拉指示器仍然显示"刷新中"
  • 必须手动向上滚动才能恢复正常
根本原因

尝试调用 PagingController 中不存在的方法:

// ❌ 这些方法不存在或行为不符合预期
_pagingController.refreshPage(data, nextPageKey: 2);
_pagingController.refreshLastPage(data);
_pagingController.itemList = data;  // 直接修改属性无效
解决方案

直接更新 PagingController.value,这是 infinite_scroll_pagination 推荐的方式:

// ✅ 正确做法:直接更新 PagingState
_pagingController.value = PagingState(
  itemList: data,              // 新数据列表
  nextPageKey: isLastPage ? null : 2,  // 下一页页码
  error: null,                 // 清除错误状态
);

// 然后通知刷新控制器完成
_refreshController.refreshCompleted();

PagingState 结构说明:

class PagingState<ItemType, PageKeyType> {
  /// 当前页的数据列表
  final List<ItemType> itemList;

  /// 下一页的页码(null 表示没有更多数据)
  final PageKeyType? nextPageKey;

  /// 错误信息
  final dynamic error;
}

7.3 问题三:鸿蒙网络图片加载失败

现象描述
  • 在 Android 模拟器上图片正常加载
  • 在鸿蒙模拟器上图片全部显示"加载失败"
  • 控制台无错误提示
根本原因分析
  1. 网络权限配置不完整:只配置了 INTERNET 权限,缺少 GET_NETWORK_INFO
  2. HTTPS 证书验证问题:某些图片服务的证书不被鸿蒙系统信任
  3. 第三方库兼容性cached_network_image 等第三方库在鸿蒙上可能存在兼容性问题
解决方案

方案 1:完善权限配置

// module.json5
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      },
      {
        "name": "ohos.permission.GET_NETWORK_INFO"
      }
    ]
  }
}

方案 2:使用 Flutter 原生 Image.network

// ✅ 使用 Flutter 自带组件,兼容性最好
Image.network(
  photo.url,
  fit: BoxFit.cover,
  errorBuilder: (context, error, stackTrace) {
    return Container(
      color: Colors.grey[200],
      child: const Center(
        child: Icon(Icons.broken_image),
      ),
    );
  },
)

方案 3:添加图片加载超时配置

Image.network(
  photo.url,
  headers: {
    'Cache-Control': 'max-age=3600',  // 缓存 1 小时
  },
  frameBuilder: (context, child, frame) {
    if (frame == null) {
      return Container(
        color: Colors.grey[100],
        child: const Center(
          child: CircularProgressIndicator(),
        ),
      );
    }
    return child;
  },
  errorBuilder: (context, error, stackTrace) {
    // 显示占位图
    return _buildPlaceholder();
  },
)

7.4 问题四:SDK 版本配置错误

现象描述
Invalid value of compileSdkVersion, compatibleSdkVersion, or targetSdkVersion
解决方案

检查并修正 ohos/build-profile.json5

{
  "app": {
    "products": [
      {
        "name": "default",
        "compatibleSdkVersion": "5.0.0(12)",    // 最低兼容版本
        "targetSdkVersion": "5.0.0(12)"         // 目标版本(不能为空)
      }
    ]
  }
}

版本号说明:

版本 API Level 说明
5.0.0(12) API 20 HarmonyOS 5.0
4.0.0(11) API 11 HarmonyOS 4.0
3.0.0(10) API 10 HarmonyOS 3.0

7.5 问题五:分页监听器未被调用

现象描述
  • 首页数据正常加载
  • 滚动到底部时 _fetchPage 方法不执行
  • 控制台无日志输出
根本原因

忘记在 initState 中绑定分页请求监听器:


void initState() {
  super.initState();
  // ❌ 忘记这行代码
  // _pagingController.addPageRequestListener(_fetchPage);

  _loadFirstPage();
}
解决方案

void initState() {
  super.initState();

  // ✅ 绑定分页监听器(必须在 initState 中完成)
  _pagingController.addPageRequestListener(_fetchPage);

  // 加载初始数据
  _loadFirstPage();
}


void dispose() {
  // ✅ 记得在 dispose 中移除监听器(通过 dispose 控制器自动完成)
  _pagingController.dispose();
  super.dispose();
}

八、性能优化与最佳实践

8.1 图片加载优化

8.1.1 使用缩略图

在列表中使用缩略图,详情页才加载原图:

// 缩略图 URL
String thumbnailUrl = ApiConfig.getThumbnailUrl(randomNum);

// 原图 URL
String originalUrl = ApiConfig.getOriginalUrl(randomNum);

// 列表项中使用缩略图
Image.network(
  photo.thumbnailUrl ?? photo.url,
  width: 200,
  height: 150,
  fit: BoxFit.cover,
)
8.1.2 图片缓存策略
// 在 Image.network 中使用 cacheWidth/cacheWidth 限制解码尺寸
Image.network(
  photo.url,
  cacheWidth: (photo.width * 0.5).toInt(),   // 缓存时缩小到 50%
  cacheHeight: (photo.height * 0.5).toInt(),
  fit: BoxFit.cover,
)

8.2 滚动性能优化

8.2.1 使用 const 构造函数
// ✅ 使用 const,减少重建
const PhotoCard(photo: photo)

// ✅ 提取静态组件
class _Divider extends StatelessWidget {
  const _Divider();

  
  Widget build(BuildContext context) {
    return const Divider(height: 1);
  }
}
8.2.2 使用 RepaintBoundary
RepaintBoundary(
  child: PhotoCard(photo: photo),
)

8.3 内存优化

// 及时清理不再使用的资源

void dispose() {
  _pagingController.dispose();
  _refreshController.dispose();
  super.dispose();
}

九、测试验证方案

9.1 功能测试清单

测试项 测试步骤 预期结果
首次加载 启动应用 显示20张图片
下拉刷新 下拉列表 刷新成功,显示新数据
连续刷新 多次下拉 每次都能成功刷新
上拉加载 滚动到底部 自动加载下一页
无更多数据 加载完所有页 显示"没有更多数据"
加载失败 断网后刷新 显示错误提示和重试按钮
重试功能 点击重试 重新发起请求

9.2 鸿蒙模拟器验证

# 启动鸿蒙模拟器
flutter devices

# 运行到鸿蒙设备
flutter run -d harmonyos

# 编译 HAP 包
flutter build hap --debug

十、项目部署与发布

10.1 Git 版本管理

# 初始化仓库
git init

# 添加 .gitignore
cat > .gitignore << EOF
# IDE
.idea/
.deveco/
.vscode/

# 构建
build/
oh_modules/

# Flutter
.flutter-plugins
.flutter-plugins-dependencies
.packages
EOF

# 提交代码
git add .
git commit -m "feat: Flutter 鸿蒙图库应用 - 下拉刷新与上拉加载

- 实现下拉刷新功能
- 实现上拉加载更多
- 完善状态管理
- 适配鸿蒙平台"

10.2 提交到远程仓库

# 添加远程仓库
git remote add origin https://atomgit.com/yourname/photo_gallery_app.git

# 推送
git push -u origin main

总结

本文详细介绍了在 Flutter 鸿蒙应用中实现下拉刷新和上拉加载的完整流程,核心要点包括:

要点 说明
状态分离 下拉刷新和上拉加载使用独立状态变量
PagingState 直接更新 value 属性来改变分页状态
网络权限 鸿蒙需要同时配置 INTERNET 和 GET_NETWORK_INFO
图片加载 优先使用 Image.network 确保兼容性
错误处理 完善的 try-catch 和 finally 确保,状态正确重置

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐