【开源鸿蒙跨平台开发先锋训练营】DAY7 第一阶段知识要点复盘

目录

【开源鸿蒙跨平台开发先锋训练营】DAY7 第一阶段知识要点复盘

摘  要

1 概述

1.1 第一阶段任务背景

1.2 第一阶段任务定位与目标

2 环境准备与功能实现

2.1 基础环境搭建(DAY1~DAY2)

2.1.1 开发环境配置

2.1.2 项目初始化

2.1.3 依赖配置

2.1.4 环境准备常见问题与排查

2.2 网络请求与数据列表(DAY3)

2.2.1 Dio 网络请求封装

2.2.2 数据模型与 JSON 解析

2.2.3 基础UI与列表功能

2.2.4 常见问题与排查

2.3 交互功能与提示优化(DAY4~DAY6)

2.3.1 分页交互机制

2.3.2 加载状态提示

2.3.3 常见问题与排查

3 代码提交至 AtomGit 公开仓库

4 第一阶段复盘总结与后续学习方向

4.1 技术收获与能力沉淀

4.2 待优化点与后续学习计划


摘  要

本文基于开源鸿蒙跨平台开发先锋训练营第一阶段(DAY1~DAY6)的学习成果,主要聚焦Flutter+开源鸿蒙项目的第一阶段核心任务。DAY7的核心目标是梳理第一阶段掌握的知识要点+创建问题解决以及对博文内容按照规则及导师意见进行优化调整,按照完成开源鸿蒙跨平台开发环境搭建、多终端工程创建运行,集成网络请求能力,实现数据清单列表,列表清单上拉加载、下拉刷新及数据加载提示能力的功能验证几项步骤。通过其中集成图片缓存组件、规范代码结构、配置鸿蒙应用签名,实现功能验证流程。本文将尽可能详细阐述总结各环节的技术原理与实操步骤,为跨平台项目的工程化落地提供参考。

1 概述

1.1 第一阶段任务背景

开源鸿蒙跨平台开发先锋训练营第一阶段通过7天递进式任务,构建从环境搭建到功能实现的技术体系。本人在开源鸿蒙跨平台开发先锋训练营第一阶段(DAY1~DAY6)中主要围绕“Flutter + 开源鸿蒙跨平台本地美食清单”应用展开,完成从开发环境搭建、核心功能实现到交互体验优化的功能实现,覆盖环境配置、Dio网络请求集成、美食清单列表UI功能实现、下拉刷新与上拉加载分页机制交互、加载状态数据提示等跨平台开发核心环节。

1.2 第一阶段任务定位与目标

DAY7是训练营第一阶段复盘,重点解决前面开发的性能短板与工程化落地问题:通过性能优化提升应用体验,验证跨平台方案的可行性与稳定性,为后续项目打好基础。

核心目标包含:

1. 梳理第一阶段知识要点,形成结构化知识框架;

2. 按技术规范,优化CSDN博文的结构、细节与可读性;

3. 总结技术收获与待优化点,明确后续学习方向。

2 环境准备与功能实现

2.1 基础环境搭建(DAY1~DAY2)

2.1.1 开发环境配置

开发工具/环境 版本规格 用途说明
操作系统 Windows 11 64 位 开发主机运行环境
VS Code 1.108.1(user setup) Flutter 业务代码编写与依赖管理
DevEco Studio 6.0.0 Release OpenHarmony 应用配置与部署
OpenHarmony SDK API Version 20(6.0.0.47) 鸿蒙应用开发核心依赖
Flutter 3.27.4(鸿蒙适配版) 跨平台 UI 框架

2.1.2 项目初始化

完成开发环境搭建后,我们就要开始创建Flutter 跨平台项目了。

1. 首先,我们可以在电脑上选择一个空间充足的磁盘位置,创建专门存放Flutter For OpenHarmony项目的文件夹,今后创建开发Flutter跨平台项目可以专门存放到这个文件夹中。比如我之前创建的“Flutter_HmProject”的文件夹就专门用来存放Flutter 项目。需要创建Flutter 跨平台项目与鸿蒙平台适配(flutter create --platforms ohos);


flutter create --platforms ohos <项目名称>

2. 工程目录规范(API 层、UI 层、模型层、配置层的目录划分逻辑)。

(详细)项目根目录/
├─ ohos/                  # OpenHarmony 原生工程目录(鸿蒙侧配置/原生代码,保持鸿蒙标准结构)
│  ├─ entry/              # 鸿蒙应用入口模块(当前的ohos/entry)
│  │  ├─ src/main/ets/    # 鸿蒙ETS代码(原生页面/能力)
│  │  │      ├─ module.json5     # 鸿蒙权限/配置文件(当前的配置)
│  │  └─ ...(鸿蒙其他原生配置)
│  │─ ...(鸿蒙其他模块)
│  │
├─ lib/                   # Flutter 业务代码目录(核心分层,重点优化)
│  ├─ core/               # 核心基础封装(复用性强的底层能力)
│  │  ├─ http/            # 网络请求封装(当前的core/http)
│  │     ├─ http_client.dart  # 网络请求工具类
│  │     └─ api_config.dart   # 网络配置(baseUrl、超时等)
│  ├─ api/                # 业务接口层(当前的api)
│  │  └─ food_api.dart    # 美食相关接口(getFoodList)
│  │
│  ├─ models/             # 数据模型层(当前的models)
│  │  ├─ food_model.dart  # 美食实体类
│  │  └─ food_model.g.dart # (若用json_serializable,自动生成的模型)
│  │
│  ├─ pages/              # 页面层(按业务模块划分,你当前的pages)
│  │  └─ food/            # 美食业务模块
│  │     └─ food_list_page.dart # 美食列表页面
│  └─ main.dart           # Flutter入口文件
├─ pubspec.yaml           # Flutter依赖配置

2.1.3 依赖配置

依赖名称 版本 功能说明
pull_to_refresh

^2.0.0

下拉刷新组件
infinite_scroll_pagination

4.0.0

上拉分页加载组件
dio

5.0.0

增强型网络请求库
json_annotation

^4.9.0

JSON 序列化注解工具

2.1.4 环境准备常见问题与排查

本人在前期环境搭建过程中,也遇到了一些问题:

1. infinite_scroll_pagination 版本兼容问题

现象:使用 3.2.0 版本时,构建代码出现两处报错:

可能原因:3.2.0 版本的库与我目前使用的鸿蒙 Flutter(高版本 Flutter)不兼容。

解决:升级到 4.0.0 版本后,兼容问题得到修复。

2. Node.js 文件修改后生效延迟

现象:修改 Node.js 相关配置/代码后,终端执行 node server.js 未立即生效,重启进程后才出现理想效果。

原因:Node.js进程默认不监听文件变化(无热重载机制),或缓存残留导致未加载新代码。

3. Flutter 依赖安装异常

现象:构建时提示 .dart_tool/package_config.json 文件缺失,报错:“Did you run this command from the same directory as your pubspec.yaml file?”。

原因:未在项目根目录(pubspec.yaml 所在目录)执行 flutter pub get,导致依赖未正确安装,配置文件未生成。

解决:需要在VS Code终端中切换到项目根目录执行 flutter pub get 生成配置文件,再执行 flutter clean 清理缓存后重建项目。

2.2 网络请求与数据列表(DAY3)

2.2.1 Dio 网络请求封装

本模块主要基于 Dio 库封装美食列表的网络请求,核心实现文件为我的项目中的 lib/api/food_api.dart文件,关键逻辑如下:

1. 请求方法封装:

  • 定义 FoodApi 类的静态方法 getFoodList,无需实例化即可调用;
  • 强制接收 page(页码)、pageSize(每页数据量)两个分页参数,保证请求参数的完整性。

2. 分页参数传递:

  • 构造 queryParams 字典封装分页参数,通过 dio.getqueryParameters 传递给后端接口,实现分页数据请求;
  • 接口地址从 ApiConfig 配置文件读取(将接口地址与请求逻辑分离,通过配置文件统一管理接口地址,避免硬编码,提升代码可维护性。)。

3. 异常与日志处理

  • 通过 try-catch 捕获请求异常,打印错误日志并向上抛异常,让页面层(food_list_page.dart)统一处理加载失败逻辑;
  • 增加请求 / 返回日志打印,便于调试时定位问题。
// food_api.dart
// 先导入依赖包
import 'package:dio/dio.dart';
import '../core/http/api_config.dart'; // 对应api_config的路径

// 定义FoodApi类
class FoodApi {
  // 改为类的静态方法,并接收分页参数page/pageSize
  static Future<dynamic> getFoodList({
    required int page,       // 页码
    required int pageSize,  // 每页数据量
  }) async {
    try {
      Dio dio = Dio();
      // 构造分页请求参数(传递给接口的query参数)
      final queryParams = {
        "page": page,
        "pageSize": pageSize,
      };
      print("请求接口:${ApiConfig.food_list_url},参数:$queryParams"); // 打印请求地址
      // 发起请求时携带分页参数,调用api_config中配置的接口地址
      Response response = await dio.get(
        ApiConfig.food_list_url,
        queryParameters: queryParams, // 关键:把分页参数传给后端接口
      );
      print("接口返回:${response.data}"); // 打印返回数据
      return response.data; // 提取接口返回的美食列表数据
    } catch (e) {
      print("接口请求失败:$e"); // 打印错误日志
      throw e; // 向上抛异常,让页面层处理
    }
  }
}

2.2.2 数据模型与 JSON 解析

food_model.dart 是数据模型与JSON解析的核心文件,包含模型定义、单模型解析、列表模型解析、解析安全处理全部关键逻辑;

1. 数据模型定义(映射 JSON 字段)

  • 定义 FoodModel 类,字段(id/name/desc/image/score)一一对应后端返回 JSON 的字段,实现 “JSON 字段 → Dart 模型属性” 的映射;
  • 定义 FoodListModel 类封装列表数据,解决 “JSON 数组 → Dart 模型列表” 的解析需求。

2. JSON 解析实现(单模型 + 列表模型)

  • 单个模型解析:

借助 json_annotation 注解(@JsonSerializable()),通过 fromJson 方法(_$FoodModelFromJson)实现 JSON 转 FoodModel 对象(解析逻辑由自动生成的 food_model.g.dart 实现,无需手动写字段映射);

同时提供 toJson 方法,支持模型对象转回 JSON(满足后续可能的 “提交数据” 场景)。

  • 列表模型解析:

FoodListModel.fromJson 手动处理 JSON 数组解析:先判断 json['foodList'] 存在且为 List 类型,再通过 map 遍历数组,逐个将子 JSON 转 FoodModel,最终组装成 FoodListModel 列表模型,避免空值 / 类型错误导致崩溃。

3. 解析安全处理(隐性但重要的点)

  • 字段用 int?/String?/double? 可空类型定义,避免后端返回 null 导致解析崩溃;
  • 列表解析时增加 json['foodList'] != null && json['foodList'] is List 双重判断,保证解析逻辑的健壮性。
// food_model.dart
import 'package:json_annotation/json_annotation.dart';
part 'food_model.g.dart'; // 关联自动生成的解析代码文件

@JsonSerializable()
class FoodModel {
  final int? id;
  final String? name; // 对应接口的name
  final String? desc; // 对应接口的desc
  final String? image; // 对应接口的image
  final double? score; // 美食评分(比如4.5、3.8)
  FoodModel({
    this.id,
    this.name,
    this.desc,
    this.image,
    this.score, // 构造函数添加score
  });
  // 自动生成的JSON转模型方法(由g.dart实现)
  factory FoodModel.fromJson(Map<String, dynamic> json) => _$FoodModelFromJson(json);
  // 自动生成的模型转JSON方法(由g.dart实现)
  Map<String, dynamic> toJson() => _$FoodModelToJson(this);
}

// 美食列表模型
class FoodListModel {
  final List<FoodModel> foodList;
  FoodListModel({required this.foodList});
  // 列表数据解析(简化格式),从JSON数组构建列表模型
  factory FoodListModel.fromJson(Map<String, dynamic> json) {
    List<FoodModel> list = [];   // 声明list变量
    if (json['foodList'] != null && json['foodList'] is List) {  // 逻辑与用&&,判断foodList存在且是List类型
      list = (json['foodList'] as List)
          .map((e) => FoodModel.fromJson(e as Map<String, dynamic>))
          .toList();
    }
    return FoodListModel(foodList: list);
  }
}

自动生成解析代码 :food_model.g.dart

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'food_model.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

FoodModel _$FoodModelFromJson(Map<String, dynamic> json) => FoodModel(
      id: (json['id'] as num?)?.toInt(),
      name: json['name'] as String?,
      desc: json['desc'] as String?,
      image: json['image'] as String?,
      score: (json['score'] as num?)?.toDouble(),
    );

Map<String, dynamic> _$FoodModelToJson(FoodModel instance) => <String, dynamic>{
      'id': instance.id,
      'name': instance.name,
      'desc': instance.desc,
      'image': instance.image,
      'score': instance.score,
    };

2.2.3 基础UI与列表功能

在 _buildFoodItem 方法中,直接使用了 Flutter 原生布局组件,并适配鸿蒙设备的布局显示:

  • Card 组件:作为列表项的容器,设置了 margin 实现鸿蒙设备上的间距兼容性;
  • Row 组件:横向布局图片与美食信息区域,适配鸿蒙设备的行布局逻辑;
  • Column 组件:纵向布局美食名称、描述、评分组件,适配鸿蒙设备的列布局逻辑。
// 列表项UI构建
Widget _buildFoodItem(FoodModel food) {
  // 新增打印,确认图片URL是否正确(复制这个URL到手机浏览器能打开)
  final imageUrl = "http://192.168.0.108:3000${food.image ?? ""}";
  print("图片请求URL:$imageUrl"); // 看Flutter控制台日志,确认URL正确
  return Card(
    margin: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
    child: Padding(
      padding: const EdgeInsets.all(10),
      child: Row(
        children: [
          // 原生Image.network
          Image.network(
            imageUrl, // 用上面拼接好的URL
            width: 80,
            height: 80,
            fit: BoxFit.cover, // 让图片填充容器
            // 加载中显示转圈
            loadingBuilder: (context, child, loadingProgress) {
              if (loadingProgress == null) return child;
              return const CircularProgressIndicator();
            },
            // 加载失败打印错误+显示图标
            errorBuilder: (context, error, stackTrace) {
              print("图片加载失败原因:${error.toString()}");
              return const Icon(Icons.fastfood);
            },
          ),
          const SizedBox(width: 12),
          // 美食信息
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                // 美食名称
                Text(
                  food.name ?? "未知美食",
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 4),
                // 美食描述
                Text(
                  food.desc ?? "暂无描述",
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: const TextStyle(fontSize: 12, color: Colors.grey),
                ),
                const SizedBox(height: 4),
                // 评分组件
                _buildNativeRating(food.score ?? 0),
              ],
            ),
          ),
        ],
      ),
    ),
  );
}

2.2.4 常见问题与排查

鸿蒙设备网络权限未配置

问题表现:在鸿蒙设备上运行应用时,接口请求无响应,控制台打印 “网络连接失败” 但无具体数据返回。

原因:未在鸿蒙工程的ohos/entry/src/main/module.json5 的 reqPermissions 节点中声明ohos.permission.INTERNET权限,导致应用无网络访问权限。

解决:在鸿蒙模块的ohos/entry/src/main/module.json5 的 reqPermissions 节点中添加权限配置:

    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]

2.3 交互功能与提示优化(DAY4~DAY6)

2.3.1 分页交互机制

1. 下拉刷新功能实现(pull_to_refresh组件的RefreshController与onRefresh回调逻辑);

pull_to_refresh 组件通过 RefreshController 管理下拉状态(初始状态、刷新中、刷新完成),通过 onRefresh 回调关联 “下拉动作” 与 “数据请求逻辑”,实现 “用户下拉 → 触发请求 → 更新列表 → 结束刷新” 的流程。

交互逻辑:

用户在页面下拉列表区域,触发 SmartRefresher 的下拉事件;

组件调用 onRefresh 绑定的 _getFoodListData(isRefresh: true) 方法;

方法内先重置分页状态(清空历史数据、恢复初始页码),再请求第 1 页数据;

数据请求成功后,替换原有列表数据,并更新分页控制器的初始数据;

最后调用 _refreshController.refreshCompleted(),结束下拉刷新动画并更新提示 UI。

// 获取美食数据(新增isRefresh参数,区分刷新/加载)
Future<void> _getFoodListData({required bool isRefresh}) async {    // _getFoodListData 方法
  // 加载锁:防止重复请求
  if (_isLoading) return;
  _isLoading = true;
  try {
    if(isRefresh){      // 下拉刷新时:重置页码、数据、状态
      _pagingController.refresh(); // 重置分页控制器,清空历史分页状态
      await Future.delayed(const Duration(seconds: 2)); // 下拉刷新→加载中 停留2秒
    } else {
      await Future.delayed(const Duration(seconds: 2));// 上拉加载时加延迟(延长“加载中...”显示时间)
    }
    // 调用API层分页请求,下拉刷新永远请求第1页
    final data = await FoodApi.getFoodList(
      page: 1, // 下拉刷新永远请求第1页
      pageSize: _pageSize,
    );
    if (mounted) {
      if (data is Map<String, dynamic>) {    // 先判断data是否是List类型
        // 解析后端返回的Map格式数据(适配后端结构)
        final foodListModel = FoodListModel.fromJson(data);
        setState(() {
          if (isRefresh) {       
            _foodList = foodListModel.foodList;   // 下拉刷新:替换原有数据
            } else {
            _foodList.addAll(foodListModel.foodList); // 上拉加载:追加新数据
            }
        });
        // 新增:下拉刷新成功后,给分页控制器赋值第一页数据
        _pagingController.value = PagingState(// 更新分页控制器:设置第一页数据与下一页页码
          nextPageKey: foodListModel.foodList.length >= _pageSize ? 2 : null,
          itemList: foodListModel.foodList,
        );
      } else {
        throw Exception("接口返回数据格式错误,不是对象格式");
      }
    }
  } catch (e) {
    if (mounted) {// 加载失败:显示错误提示
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text("加载失败:${e.toString()}")),
      );
    }
  } finally {
      _isLoading = false;
      if (isRefresh) {// 下拉刷新成功提示
        Future.delayed(const Duration(seconds: 2), () {
          if (mounted) {
            _refreshController.refreshCompleted();// 结束下拉刷新动画(必须调用,否则刷新状态不会重置)
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar(content: Text("美食列表加载成功")),
            );
          }
        });
      }
  }
}

2. 上拉加载分页功能实现(infinite_scroll_pagination的PagingController与addPageRequestListener监听)。

infinite_scroll_pagination 组件通过 PagingController 管理分页状态(当前页码、数据列表、是否有更多数据),通过 addPageRequestListener 监听 “列表滑到底部” 的事件,自动触发对应页码的数据请求;再通过 PagedListView 根据控制器状态渲染列表、加载中 / 无更多数据等提示。

交互逻辑:

用户滑动列表到最底部,触发 PagingController 的 addPageRequestListener 监听;

监听传入当前 “页码 key”,调用 _fetchPage(pageKey) 方法;

方法内请求对应页码的数据,解析后判断是否为最后一页(返回数据量 < 每页大小);

若为最后一页:调用 appendLastPage,分页控制器标记 “无更多数据”,PagedListView 渲染 “已加载全部数据” 提示;

若不是最后一页:调用 appendPage,追加新数据并传入 “下一页 key”,PagedListView 自动渲染新数据,等待用户再次滑到底部触发下一页请求。

// 上拉加载更多专用方法(pageKey为当前要加载的页码)
  // 新增:infinite_scroll_pagination 专用分页请求方法
  Future<void> _fetchPage(int pageKey) async {
    if (_isLoading) return;
    _isLoading = true;
    try {
      await Future.delayed(const Duration(seconds: 2));// 延长加载提示显示
      final data = await FoodApi.getFoodList(  // 调用API层,请求当前页码数据
        page: pageKey,
        pageSize: _pageSize,
      );
      if (mounted && data is Map<String, dynamic>) {
        final foodListModel = FoodListModel.fromJson(data);
        final newItems = foodListModel.foodList;
        // 判断是否为最后一页(返回数据量 < 每页大小 → 无更多数据)
        final isLastPage = newItems.length < _pageSize;
        if (isLastPage) {
          // 无更多数据:通知分页控制器停止上拉加载
          _pagingController.appendLastPage(newItems);
        } else {
          // 有更多数据,自动加载下一页,页码+1,无限上拉核心逻辑
          final nextPageKey = pageKey + 1;
          _pagingController.appendPage(newItems, nextPageKey);
        }
      }
    } catch (e) {
      // 加载失败:通知分页控制器显示失败提示
      _pagingController.error = e;
      if(mounted){
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text("加载失败:${e.toString()}")),
        );
      }
    } finally {
      _isLoading = false;
    }
  }

2.3.2 加载状态提示

1. 全场景状态提示

加载状态提示通过 pull_to_refresh 和 infinite_scroll_pagination 的状态构建器,结合请求逻辑中的提示,实现全场景状态的 UI 反馈,包括:初始化加载、刷新中、加载中、无更多数据、加载失败的 UI 实现等。

(1)刷新中提示

      // 自定义下拉刷新头部(刷新中状态)  
      header: CustomHeader(
        builder: (context, mode) {
          String headerText = "";
          Color textColor = Colors.black87; // 初始文字颜色
          // 根据刷新状态切换文案和颜色
          if (mode == RefreshStatus.refreshing) {
            headerText = "刷新中"; // 刷新中显示“刷新中”
            textColor = Colors.blueAccent; // 加载中用橙色,更醒目
          } 
          // 其他状态处理...  
          return Container(
            height: 60,
            alignment: Alignment.center,
            child: Text(headerText,          // 给Text添加style,应用textColor和字体样式
              style: TextStyle(
                color: textColor, // 应用定义的文字颜色
                fontSize: 16, // 加大字号,更醒目
                fontWeight: FontWeight.w400, // 加粗字体
              ),
            ), // 显示对应的提示文本
          );
        },
      ),

原理:pull_to_refresh 组件的 CustomHeader 会根据下拉刷新的状态(mode)动态回调,通过判断 mode == RefreshStatus.refreshing 识别 “刷新中” 状态,从而更新 UI 文案与样式。

逻辑:用户下拉列表触发刷新 → 组件自动切换到 RefreshStatus.refreshing 状态 → 回调 builder 方法 → 渲染 “刷新中” 提示文案,让用户感知刷新进度。

(2)加载中提示

            newPageProgressIndicatorBuilder: (_) => Container(
              height: 60,
              alignment: Alignment.center,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  CircularProgressIndicator(strokeWidth: 2),
                  SizedBox(width: 8),
                  Text("加载中..."),
                ],
              ),
            ),

原理:infinite_scroll_pagination 的 PagedListView 会监听 PagingController 的 “请求中” 状态,自动触发 newPageProgressIndicatorBuilder 构建加载中 UI。

逻辑:用户滑动列表到底部 → PagingController 触发分页请求 → 列表自动渲染 “加载中...” 的转圈 + 文字提示,避免用户重复滑动,同时明确加载状态。

(3)无更多数据提示

            noMoreItemsIndicatorBuilder: (_) => Container(   // 无更多数据提示
              height: 60, 
              alignment: Alignment.center,
              child: const Text("已加载全部数据"),
            ),

原理:当分页请求返回的数据量小于每页大小(isLastPage = newItems.length < _pageSize)时,调用 _pagingController.appendLastPage(newItems),PagingController 会标记 “无更多数据”,进而触发 noMoreItemsIndicatorBuilder。

逻辑:上拉请求返回数据不足一页 → 判定为 “无更多数据” → 分页控制器通知列表 → 渲染 “已加载全部数据” 提示,告知用户无需继续滑动。

(4)加载失败提示

// 1. 初始化加载失败提示
firstPageErrorIndicatorBuilder: (_) => Container(
  height: 60,
  alignment: Alignment.center,
  child: const Text("加载失败,下拉重试"),
),

// 2. 上拉加载失败提示
newPageErrorIndicatorBuilder: (_) => Container(
  height: 60,
  alignment: Alignment.center,
  child: const Text("加载失败,上拉重试"),
),

// 3. SnackBar操作提示(请求失败时)
ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(content: Text("加载失败:${e.toString()}")),
),

原理:

列表层面的失败提示:PagingController 捕获请求异常后标记 “错误状态”,触发对应的失败构建器;

SnackBar 提示:通过 ScaffoldMessenger 全局触发轻量级提示,是 Flutter 原生的跨平台反馈组件。

逻辑:

请求过程中捕获异常 → 同时触发两种反馈:

(数据)列表层面:渲染 “加载失败,下拉/上拉重试” 的静态提示,引导用户操作;

全局提示:弹出 SnackBar 显示具体错误信息,让用户快速感知失败原因。

2. 原生组件扩展

这部分通过 Flutter 原生组件实现扩展,包括:自定义星级评分_buildNativeRating、SnackBar 操作提示的跨平台适配,天然适配鸿蒙等跨平台场景:

(1)自定义星级评分(_buildNativeRating)

// 原生星星评分组件(无需第三方包)
Widget _buildNativeRating(double score) {
  const int maxStars = 5; // 满分5星
  final int fullStars = score.floor(); // 全星数量(比如4.5→4)
  final bool hasHalfStar = score - fullStars >= 0.5; // 是否有半星
  final int emptyStars = maxStars - fullStars - (hasHalfStar ? 1 : 0); // 空星数量
  return Row(
    children: [
      // 全星
      ...List.generate(fullStars, (index) => const Icon(
        Icons.star,size: 18,color: Colors.amber,
      )),
      // 半星(如果有)
      if (hasHalfStar)
        const Icon(
          Icons.star_half,size: 18,color: Colors.amber,
        ),
      // 空星
      ...List.generate(emptyStars, (index) => const Icon(
        Icons.star_border,size: 18,color: Colors.amber,
      )),
    ],
  );
}

原理:利用 Flutter 原生 Row + Icon 组件,通过 Dart 的列表生成(List.generate)和条件判断,动态拼接 “全星 + 半星 + 空星” 的 UI 结构,无需依赖第三方包。

逻辑:传入美食评分(如 4.5)→ 计算全星(4)、半星(是)、空星(0)的数量 → 动态生成对应 Icon 组成 Row → 渲染出符合评分的星级 UI,同时因使用 Flutter 原生组件,天然适配鸿蒙设备的布局与样式。

(2)SnackBar 操作提示

// 请求失败时的SnackBar提示
ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(content: Text("加载失败:${e.toString()}")),
);

// 刷新成功时的SnackBar提示
ScaffoldMessenger.of(context).showSnackBar(
  const SnackBar(content: Text("美食列表加载成功")),
);

原理:SnackBar‌ 是 Flutter 框架中的一个内置 UI 组件,Flutter 框架会自动处理其在鸿蒙等平台的渲染逻辑,保证跨平台兼容性。

逻辑:请求成功/失败时 → 调用 ScaffoldMessenger.of(context).showSnackBar → 在页面底部弹出轻量级提示框 → 告知用户操作结果,同时适配鸿蒙设备的交互规范(如提示框的显示/消失动画、布局位置)。

2.3.3 常见问题与排查

1. 下拉刷新后动画一直转圈,无数据返回

问题表现:下拉触发刷新后,“刷新中” 动画持续转圈,页面无新数据加载,也不显示 “刷新完成” 提示。

核心原因:

未调用 _refreshController.refreshCompleted():请求完成后未通知下拉刷新控制器 “结束状态”,导致动画一直保持 “刷新中”;

请求异常时未重置状态:请求失败后未处理加载锁 / 控制器状态,导致控制器一直处于 “加载中”;

分页控制器未正确重置:下拉刷新时未调用 _pagingController.refresh(),历史分页状态残留导致新数据无法渲染。

解决方法:

无论请求成功/失败,在 finally 代码块中调用 _refreshController.refreshCompleted(),强制结束刷新动画;

下拉刷新时必须执行 _pagingController.refresh(),清空分页控制器的历史状态;

请求异常时(catch 块)手动重置 _isLoading = false,避免加载锁一直生效。

2. 上拉加载一直无数据(滑到底部无反应/加载失败)

问题表现:滑动列表到底部后,不显示 “加载中” 提示,也无新数据加载;或显示 “加载中” 后一直无结果。

核心原因:

页码参数传递错误:_fetchPage 中调用 FoodApi.getFoodList 时,未将 pageKey 作为 page 参数传递(比如写死为固定页码);

分页控制器的 nextPageKey 未正确设置:请求成功后,未根据 “当前页数据量” 判断是否有下一页,导致 nextPageKey 为空 / 未递增;

后端返回数据格式不匹配:接口返回数据不是 Map<String, dynamic> 格式,导致 FoodListModel.fromJson 解析失败,新数据无法追加。

解决方法:

确保 FoodApi.getFoodList(page: pageKey) 中 page 参数直接使用 pageKey(随上拉页码动态变化);

请求成功后,通过 newItems.length >= _pageSize 判断是否有下一页,正确设置 nextPageKey = pageKey + 1;

增加数据格式校验(如 if (data is Map<String, dynamic>)),避免解析异常导致数据丢失。

3 代码提交至 AtomGit 公开仓库

1. 在 AtomGit 创建个人公开仓库

项目功能初步完善后,我们可以将自己的项目上传到AtomGit 个人公开仓库中以便参考学习,首先需要在AtomGit官网有自己的帐号,之后创建好关于Flutter+开源鸿蒙个人项目的公开仓库,后续开发在该仓库中提交。

2. 准备本地工程与 Git 环境

首先,我们要确保工程根目录包含全部文件(lib源码、ohos鸿蒙工程配置、build编译文件等),之后开始初始化 Git 仓库(若未初始化),需要打开终端,进入工程根目录,比如我在前面博客中创建的D:\Flutter_HmProject\flutter_harmonyos项目,

cd D:\Flutter_HmProject\flutter_harmonyos       # 先进入到工程根目录中

git init

配置 Git 用户信息/Git 全局设置(首次使用需配置):

git config --global user.name "你的AtomGit用户名"
 
git config --global user.email "你的AtomGit绑定邮箱"

输入以下命令,可以查看当前的 Git 配置信息,确认用户名和邮箱是否已经正确设置:

git config --list

3. 本地工程关联 AtomGit 仓库

将本地工程与 AtomGit 远程仓库关联(仓库地址是你的 AtomGit 仓库地址):

git remote add origin https://atomgit.com/你的用户名/仓库名称.git

4. 代码提交至 AtomGit 仓库

代码提交至 AtomGit 仓库前先要将本地工程文件添加到 Git 暂存区:

git add .

提交代码(填写清晰的 commit 信息):

git commit -m "Initial commit: Flutter+鸿蒙跨平台工程初始化"

执行推送命令(首次推送关联分支),将代码推送至 AtomGit 远程仓库:

git push -f origin main

首次将代码推送至 AtomGit 远程仓库,会提示身份验证,需要输入你的 AtomGit 账号 + 密码,或用访问令牌替代密码。

5. 验证推送结果

最后,进行验证推送结果,需要刷新你的 AtomGit 仓库页面,确认工程配置文件、源码、资源、调试日志等文件已全部显示,完成代码提交任务。

4 第一阶段复盘总结与后续学习方向

4.1 技术收获与能力沉淀

在开源鸿蒙跨平台开发先锋训练营第一阶段(DAY1~DAY6)中,我主要围绕“Flutter + 开源鸿蒙本地美食清单”应用进行开发,我完成了从“环境搭建→功能实现→交互优化”的技术步骤,具体收获如下:

1. 跨平台开发环境搭建能力

在前期学习准备过程中,我能够通过部署环境掌握到 Windows 11 系统下 Flutter + 开源鸿蒙的开发环境配置逻辑:包括 Flutter SDK、DevEco Studio、HarmonyOS SDK 的安装与版本适配,实现了 VS Code(Flutter 编码)与 DevEco Studio(鸿蒙打包)的工具协同,同时掌握了运行工具对开源鸿蒙模拟器的连接、调试流程,能够独立解决设备识别、调试权限等环境类基础问题。

2. 核心功能开发与技术落地能力

(1)网络请求层:完成了 Dio 网络请求库的封装,实现了 “美食列表分页接口” 的参数传递(page/pageSize)、异常捕获与错误透传,能适配后端 Map 格式的返回数据;

(2)数据处理层:设计了FoodModel/FoodListModel的数据模型,掌握了fromJson方法对 JSON 数据的解析逻辑,实现了字段空安全、默认值处理等可扩展设计;

(3)UI 与列表层:完成了 “本地美食清单” 的基础 UI 渲染(Card/Row等组件的跨平台布局),能基于业务需求封装自定义组件(如原生星级评分_buildNativeRating)。

3. 交互体验优化与用户反馈能力

在第一阶段项目完善过程中,也实现了全场景的交互功能与状态提示:通过pull_to_refresh组件完成下拉刷新逻辑,借助infinite_scroll_pagination实现上拉加载分页机制,同时覆盖了“初始化加载、刷新中、加载中、无更多数据、加载失败”的场景状态UI提示,掌握了SnackBar等原生组件的跨平台适配方法,能够通过细节设计来提升应用的用户体验。

4. 问题排查与工程化思维

在实现不同功能过程中,我也遇到不同问题,但最终都已合理解决掉,从而形成了“问题场景→分层排查→方法解决” 的技术思维:针对接口请求失败、列表卡顿、数据解析异常等问题,能通过 “网络连通性校验→权限配置检查→代码逻辑定位” 的步骤快速进行排查;同时建立了“API 层 - UI 层 - 模型层 - 配置层”的工程目录规范,以此来保证代码的可维护性与复用性。

4.2 待优化点与后续学习计划

结合第一阶段的开发实践,目前仍存在技术深度与内容输出的优化空间,后续大致学习计划如下:

1. 技术深度提升计划

(1)拓展鸿蒙原生能力:目前仅实现了基础跨平台功能,后续计划探索 Flutter 与开源鸿蒙原生能力的交互,如调用系统相册、申请位置权限等,提升应用的原生适配性;

(2)优化性能瓶颈:针对列表图片加载卡顿的问题,后续将完善图片缓存策略(如集成cached_network_image),同时优化分页数据量的动态控制逻辑。

2. 内容输出与知识沉淀计划

(1)系列博文一致性优化:持续统一技术术语(如规范使用一些专业术语的表述,方便理解);

(2)强化技术佐证材料:后续将补充项目应用运行效果图、代码修复前后的对比片段等内容,增强博文的真实性与说服力。

欢迎加入开源鸿蒙跨平台社区:

https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐