【HarmonyOS】Flutter 鸿蒙实战:图库应用的下拉刷新与上拉加载完整实现
// 图片数据模型////// 包含图片的基本信息和计算属性/// 唯一标识符/// 图片 URL/// 缩略图 URL/// 图片宽度(像素)/// 图片高度(像素)/// 图片描述/// 作者信息author;/// 创建时间createdAt;});/// 从 JSON 创建对象?'',?'',????0,????0,= null?: null,/// 转换为 JSONreturn {'id
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;
}
}
冲突场景:
- 用户上拉加载,触发
_fetchPage,_isLoading = true - 用户立即下拉刷新,被
_isLoading检查阻止 - 上拉加载完成后
_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 模拟器上图片正常加载
- 在鸿蒙模拟器上图片全部显示"加载失败"
- 控制台无错误提示
根本原因分析
- 网络权限配置不完整:只配置了
INTERNET权限,缺少GET_NETWORK_INFO - HTTPS 证书验证问题:某些图片服务的证书不被鸿蒙系统信任
- 第三方库兼容性:
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 确保,状态正确重置 |
更多推荐




所有评论(0)