【Harmonyos】开源鸿蒙跨平台训练营DAY10: 获取特惠推荐数据

Flutter鸿蒙开发指南(十):获取特惠推荐数据(AI)


🌸你好呀!我是 lbb小魔仙
🌟 感谢陪伴~ 小白博主在线求友
🌿 跟着小白学Linux/Java/Python
📖 专栏汇总:
《Linux》专栏 | 《Java》专栏 | 《Python》专栏

前言

在电商应用中,特惠推荐模块是吸引用户点击、提升转化率的重要功能。本文将详细介绍如何在 Flutter/HarmonyOS 项目中实现特惠推荐数据的获取与渲染功能,完整展示从数据模型构建到 UI 展示的开发流程,并记录开发过程中遇到的问题及解决方案。

一、实现流程

按照规范的六步开发流程:

复制代码
(1)定义接口常量 - 添加特惠推荐接口地址
(2)构建数据模型 - 根据JSON生成数据模型类
(3)封装API调用 - 实现数据获取接口
(4)首页数据初始化 - 在组件中获取数据
(5)传递数据到子组件 - 更新UI组件接收参数
(6)完善UI展示 - 实现商品列表渲染

1.1 特惠推荐接口介绍

接口地址https://meikou-api.itheima.net/hot/preference

返回数据结构

json 复制代码
{
  "code": "1",
  "msg": "操作成功",
  "result": {
    "id": "897682543",
    "title": "特惠推荐",
    "subTypes": [
      {
        "id": "912000341",
        "title": "抢先尝鲜",
        "goodsItems": {
          "counts": 459,
          "pageSize": 10,
          "pages": 46,
          "page": 1,
          "items": [
            {
              "id": "1750713979950333956",
              "name": "Balva 日本制高级时尚太阳镜 方框",
              "desc": "抵挡99%紫外线太阳镜",
              "price": "1213.00",
              "picture": "https://...",
              "orderNum": 17
            }
          ]
        }
      }
    ]
  }
}

二、代码实现

2.1 定义接口常量

文件lib/constants/index.dart

dart 复制代码
// 存放请求地址接口的常量
class HttpConstants {
  // 轮播图接口
  static const String BANNER_LIST = "/home/banner";

  // 分类列表接口
  static const String CATEGORY_LIST = "/home/category/head";

  // 特惠推荐地址
  static const String PRODUCT_LIST = "/hot/preference";
}

2.2 构建数据模型

文件lib/viewmodels/home.dart

根据 JSON 数据结构,我们需要创建四个数据模型类:

dart 复制代码
// 商品项
class GoodsItem {
  String id;
  String name;
  String? desc;
  String price;
  String picture;
  int orderNum;

  GoodsItem({
    required this.id,
    required this.name,
    this.desc,
    required this.price,
    required this.picture,
    required this.orderNum,
  });

  factory GoodsItem.fromJSON(Map<String, dynamic> json) {
    return GoodsItem(
      id: json["id"] ?? "",
      name: json["name"] ?? "",
      desc: json["desc"],
      price: json["price"] ?? "",
      picture: json["picture"] ?? "",
      orderNum: json["orderNum"] ?? 0,
    );
  }
}

// 商品列表
class GoodsItems {
  int counts;
  int pageSize;
  int pages;
  int page;
  List<GoodsItem> items;

  GoodsItems({
    required this.counts,
    required this.pageSize,
    required this.pages,
    required this.page,
    required this.items,
  });

  factory GoodsItems.fromJSON(Map<String, dynamic> json) {
    return GoodsItems(
      counts: json["counts"] ?? 0,
      pageSize: json["pageSize"] ?? 0,
      pages: json["pages"] ?? 0,
      page: json["page"] ?? 0,
      items: (json["items"] as List?)
          ?.map((item) => GoodsItem.fromJSON(item as Map<String, dynamic>))
          .toList() ?? [],
    );
  }
}

// 子类型
class SubType {
  String id;
  String title;
  GoodsItems goodsItems;

  SubType({
    required this.id,
    required this.title,
    required this.goodsItems,
  });

  factory SubType.fromJSON(Map<String, dynamic> json) {
    return SubType(
      id: json["id"] ?? "",
      title: json["title"] ?? "",
      goodsItems: GoodsItems.fromJSON(json["goodsItems"] ?? {}),
    );
  }
}

// 特惠推荐结果
class SpecialOfferResult {
  String id;
  String title;
  List<SubType> subTypes;

  SpecialOfferResult({
    required this.id,
    required this.title,
    required this.subTypes,
  });

  factory SpecialOfferResult.fromJSON(Map<String, dynamic> json) {
    return SpecialOfferResult(
      id: json["id"] ?? "",
      title: json["title"] ?? "",
      subTypes: (json["subTypes"] as List?)
          ?.map((item) => SubType.fromJSON(item as Map<String, dynamic>))
          .toList() ?? [],
    );
  }
}

关键点

  • 使用 factory 关键字声明工厂函数
  • 使用空安全操作符 ?? 提供默认值
  • desc 字段使用 String? 可空类型(API 可能返回 null)
  • items 使用可空列表处理 as List?

2.3 封装 API 调用

文件lib/api/home.dart

dart 复制代码
/// 获取特惠推荐数据
Future<SpecialOfferResult> getProductListAPI() async {
  // 返回请求
  return SpecialOfferResult.fromJSON(
      await dioRequest.get(HttpConstants.PRODUCT_LIST));
}

2.4 首页数据初始化

文件lib/pages/home/index.dart

dart 复制代码
class _HomeViewState extends State<HomeView> {
  // 分类列表
  List<CategoryItem> _categoryList = [];

  // 轮播图列表
  List<BannerItem> _bannerList = [];

  // 特惠推荐
  SpecialOfferResult _specialOfferResult = SpecialOfferResult(
    id: "",
    title: "",
    subTypes: [],
  );

  @override
  void initState() {
    super.initState();
    _getBannederList();
    _getCategoryList();
    _getProductList();  // 获取特惠推荐数据
  }

  // 获取特惠推荐
  void _getProductList() async {
    try {
      _specialOfferResult = await getProductListAPI();
      setState(() {});
    } catch (e) {
      print('获取特惠推荐数据失败: $e');
    }
  }

  // 在滚动视图中使用
  List<Widget> _getScrollChildren() {
    return [
      SliverToBoxAdapter(child: HmSlider(bannerList: _bannerList)),
      const SliverToBoxAdapter(child: SizedBox(height: 10)),
      SliverToBoxAdapter(child: HmCategory(categoryList: _categoryList)),
      const SliverToBoxAdapter(child: SizedBox(height: 10)),
      SliverToBoxAdapter(
          child: HmSuggestion(specialOfferResult: _specialOfferResult)), // 推荐组件
      // ...
    ];
  }
}

2.5 更新特惠推荐组件

文件lib/components/Home/HmSuggestion.dart

dart 复制代码
import 'package:flutter/material.dart';
import 'package:harmonyos_day_four/viewmodels/home.dart';

class HmSuggestion extends StatefulWidget {
  // 父传子
  final SpecialOfferResult specialOfferResult;

  const HmSuggestion({super.key, required this.specialOfferResult});

  @override
  State<HmSuggestion> createState() => _HmSuggestionState();
}

class _HmSuggestionState extends State<HmSuggestion> {
  // 取前三条数据
  List<GoodsItem> _getDisplayItems() {
    // 在初始化还没获取到数据直接返回空列表
    if (widget.specialOfferResult.subTypes.isEmpty) {
      return [];
    }

    return widget.specialOfferResult.subTypes.first.goodsItems.items
        .take(3)
        .toList();
  }

  Widget _buildHeader() {
    return Row(
      children: [
        const Text(
          "特惠推荐",
          style: TextStyle(
              color: Color.fromARGB(255, 86, 24, 20),
              fontSize: 18,
              fontWeight: FontWeight.w700),
        ),
        const SizedBox(width: 10),
        Text(
          widget.specialOfferResult.title.isNotEmpty
              ? widget.specialOfferResult.title
              : "精选省攻略",
          style: const TextStyle(
            fontSize: 12,
            color: Color.fromARGB(255, 124, 63, 58),
          ),
        ),
      ],
    );
  }

  // 左侧结构 - 渐变背景
  Widget _buildLeft() {
    return Container(
      width: 75,
      height: 140,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(10),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [
            Color.fromARGB(255, 255, 120, 50),  // 橙红
            Color.fromARGB(255, 255, 180, 60),  // 橙黄
          ],
        ),
        boxShadow: [
          BoxShadow(
            color: const Color.fromARGB(255, 255, 140, 50).withOpacity(0.3),
            blurRadius: 8,
            offset: const Offset(2, 4),
          ),
        ],
      ),
      child: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.card_giftcard, color: Colors.white, size: 36),
            SizedBox(height: 8),
            Text(
              "特惠",
              style: TextStyle(
                color: Colors.white,
                fontSize: 16,
                fontWeight: FontWeight.bold,
              ),
            ),
            SizedBox(height: 4),
            Text(
              "限时",
              style: TextStyle(
                color: Colors.white,
                fontSize: 11,
              ),
            ),
          ],
        ),
      ),
    );
  }

  // 构建商品图片
  Widget _buildProductImage(String imageUrl) {
    // 检查URL是否为空
    if (imageUrl.isEmpty) {
      return _buildErrorImage();
    }

    // 将 http:// 转换为 https:// 以解决明文流量问题
    String secureUrl = imageUrl;
    if (secureUrl.startsWith('http://')) {
      secureUrl = secureUrl.replaceFirst('http://', 'https://');
    }

    return SizedBox(
      width: double.infinity,
      height: 140,
      child: Image.network(
        secureUrl,
        fit: BoxFit.cover,
        loadingBuilder: (context, child, loadingProgress) {
          if (loadingProgress == null) return child;
          return _buildLoadingImage(loadingProgress);
        },
        errorBuilder: (context, error, stackTrace) {
          // 如果 https 失败,尝试用原 url (http)
          if (secureUrl != imageUrl) {
            return SizedBox(
              width: double.infinity,
              height: 140,
              child: Image.network(
                imageUrl,
                fit: BoxFit.cover,
                errorBuilder: (context, error, stackTrace) {
                  return _buildErrorImage();
                },
              ),
            );
          }
          return _buildErrorImage();
        },
      ),
    );
  }

  // 加载中图片
  Widget _buildLoadingImage(ImageChunkEvent loadingProgress) {
    return Container(
      width: double.infinity,
      height: 140,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        color: Colors.grey[200],
      ),
      child: Center(
        child: CircularProgressIndicator(
          value: loadingProgress.expectedTotalBytes != null
              ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
              : null,
          strokeWidth: 2,
        ),
      ),
    );
  }

  // 错误图片占位符
  Widget _buildErrorImage() {
    return Container(
      width: double.infinity,
      height: 140,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        color: Colors.grey[300],
      ),
      child: const Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.image_not_supported, size: 32, color: Colors.grey),
            SizedBox(height: 4),
            Text(
              "图片加载失败",
              style: TextStyle(fontSize: 10, color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }

  List<Widget> _getChildrenList() {
    List<GoodsItem> list = _getDisplayItems();
    return List.generate(list.length, (int index) {
      return Expanded(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 4.0),
          child: Column(
            children: [
              ClipRRect(
                borderRadius: BorderRadius.circular(8),
                child: _buildProductImage(list[index].picture),
              ),
              const SizedBox(height: 10),
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(12),
                  color: const Color.fromARGB(255, 240, 96, 12),
                ),
                child: Text(
                  "¥${list[index].price}",
                  style: const TextStyle(color: Colors.white, fontSize: 11),
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                ),
              )
            ],
          ),
        ),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10),
        child: Container(
            alignment: Alignment.center,
            padding: const EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: const Color.fromARGB(255, 255, 246, 238),
              borderRadius: BorderRadius.circular(12),
            ),
            child: Column(
              children: [
                _buildHeader(),
                const SizedBox(height: 10),
                Row(
                  children: [
                    _buildLeft(),
                    const SizedBox(width: 4),
                    Expanded(
                        child: Row(
                            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                            children: _getChildrenList()))
                  ],
                )
              ],
            )));
  }
}

三、遇到问题及解决方法

问题1:空数据导致崩溃

错误现象

复制代码
RangeError: RangeError (index): Invalid value: Valid value range is empty: 0

原因分析 :在数据还没加载完成时,subTypes 为空列表,直接访问 first 会导致崩溃。

解决方法 :在 _getDisplayItems() 中添加空数据检查

dart 复制代码
List<GoodsItem> _getDisplayItems() {
  // 在初始化还没获取到数据直接返回空列表
  if (widget.specialOfferResult.subTypes.isEmpty) {
    return [];
  }

  return widget.specialOfferResult.subTypes.first.goodsItems.items
      .take(3)
      .toList();
}

问题2:HTTP 图片加载失败

错误现象:某些商品图片无法显示,控制台无明确错误。

原因分析

  1. API 返回的某些图片 URL 使用 http:// 而非 https://
  2. HarmonyOS 默认禁止明文 HTTP 流量

解决方法 :自动将 http:// 转换为 https://

dart 复制代码
Widget _buildProductImage(String imageUrl) {
  if (imageUrl.isEmpty) {
    return _buildErrorImage();
  }

  // 将 http:// 转换为 https://
  String secureUrl = imageUrl;
  if (secureUrl.startsWith('http://')) {
    secureUrl = secureUrl.replaceFirst('http://', 'https://');
  }

  return Image.network(
    secureUrl,
    errorBuilder: (context, error, stackTrace) {
      // 如果 HTTPS 失败,尝试原 HTTP URL
      if (secureUrl != imageUrl) {
        return Image.network(imageUrl, ...);
      }
      return _buildErrorImage();
    },
  );
}

问题3:配置文件结构错误

错误现象

复制代码
hvigor ERROR: 00303038 Configuration Error
Schema validate failed, module.json5
property 'network' must be valid

原因分析 :尝试在 module.json5 根级别添加 network 配置,但 JSON Schema 不允许。

解决方法:使用纯代码解决方案,通过自动转换 URL 处理 HTTP 问题,而非依赖配置文件。

问题4:图片加载无反馈

错误现象:网络慢时,图片区域长时间空白。

解决方法 :添加 loadingBuilder 显示加载进度

dart 复制代码
Image.network(
  secureUrl,
  loadingBuilder: (context, child, loadingProgress) {
    if (loadingProgress == null) return child;
    return _buildLoadingImage(loadingProgress);
  },
)

Widget _buildLoadingImage(ImageChunkEvent loadingProgress) {
  return Container(
    child: Center(
      child: CircularProgressIndicator(
        value: loadingProgress.expectedTotalBytes != null
            ? loadingProgress.cumulativeBytesLoaded /
               loadingProgress.expectedTotalBytes!
            : null,
      ),
    ),
  );
}

问题5:desc 字段可能为 null

错误现象

复制代码
type 'Null' is not a subtype of type 'String'

原因分析 :API 返回的商品数据中 desc 字段可能为 null

解决方法 :使用可空类型 String?

dart 复制代码
class GoodsItem {
  String? desc;  // 使用可空类型
  // ...
}

四、效果展示

特惠推荐模块展示效果:

复制代码
┌─────────────────────────────────────────────────────────────┐
│  特惠推荐  精选省攻略                                          │
│  ┌──────────┐  ┌─────┐ ┌─────┐ ┌─────┐                     │
│  │          │  │图  │ │图  │ │图  │                     │
│  │   🎁     │  │片  │ │片  │ │片  │                     │
│  │  特惠    │  │ ¥xx │ ¥xx │ ¥xx │                     │
│  │  限时    │  │     │ │     │ │     │                     │
│  │          │  └─────┘ └─────┘ └─────┘                     │
│  └──────────┘                                               │
└─────────────────────────────────────────────────────────────┘

UI 特点

  • 左侧:橙红到橙黄渐变背景 + 礼品图标 + 双层文字
  • 右侧:前3条商品卡片,每张显示图片和价格
  • 背景:浅米白色容器,圆角设计
  • 阴影:柔和的橙色光晕效果

五、总结

本文介绍了 Flutter/HarmonyOS 电商应用中特惠推荐功能的完整实现流程:

  1. 数据模型:四层嵌套结构(GoodsItem → GoodsItems → SubType → SpecialOfferResult)
  2. 网络请求:封装 API 调用,统一处理业务状态码
  3. UI 渲染:渐变背景、图片加载优化、错误处理
  4. 问题解决:空数据检查、HTTP/HTTPS 兼容、加载进度显示

通过遵循规范的六步开发流程,可以快速实现类似的数据获取与展示功能。

源码地址

https://atomgit.com/lbbxmx111/haromyos_day_four

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

📕个人领域 :Linux/C++/java/AI

🚀 个人主页有点流鼻涕 · CSDN

💬 座右铭 : "向光而行,沐光而生。"

相关推荐
冬奇Lab3 小时前
一天一个开源项目(第53篇):PDF 补丁丁 - 功能全面的 PDF 工具箱,编辑书签、解除限制、合并拆分、OCR 识别
开源·资讯
Arya_aa3 小时前
Mysql数据库-管理和存储数据库(开源管理系统)与JDBC操作数据库步骤,JUnit以及如何将压缩包中exe程序添加上桌面图标
数据库·mysql·junit·开源
国医中兴4 小时前
Flutter 三方库 stack_blur 鸿蒙适配指南 - 实现工业级高性能模糊滤镜、在 OpenHarmony 上打造极致视觉质感实战
flutter·华为·harmonyos
沐曦股份MetaX6 小时前
再升级!沐曦股份 GPU 接入华佗开源生态!
开源
aiAIman6 小时前
OpenClaw 生态主流 AI 模型真实性能 PinchBench深度解读(基于2026年3月12日测评数据)
人工智能·开源·aigc
IvorySQL7 小时前
官宣!全球 PostgreSQL 大神再度集结,HOW 2026 正式定档
数据库·postgresql·开源
Swift社区10 小时前
AI 原生鸿蒙应用开发实战
人工智能·华为·harmonyos
盐焗西兰花10 小时前
鸿蒙学习实战之路-Share Kit系列(12/17)-判断应用是否被系统分享拉起
前端·学习·harmonyos
一知半解仙10 小时前
AI视频生成真实能力解析
人工智能·智能手机·架构·开源