在这里插入图片描述

商品分类是电商应用的核心功能。一个好的分类系统可以帮助用户快速找到他们想要的商品,提升用户体验。这篇文章会详细讲解如何实现一个功能完整的商品分类系统,包括分类列表、分类详情、排序、筛选等功能。

商品分类的重要性

商品分类看似简单,但它对应用的成功有很大的影响。

帮助用户快速找到商品 - 用户进入应用后,首先需要找到他们想要的商品。一个好的分类系统可以让用户快速定位到相关的商品分类。

提升转化率 - 当用户能够快速找到他们想要的商品时,他们更可能进行购买。这直接提升了应用的转化率。

降低用户流失 - 如果用户无法快速找到他们想要的商品,他们可能会离开应用。一个好的分类系统可以降低用户流失。

支持个性化推荐 - 通过分析用户在不同分类中的行为,可以为用户提供个性化的商品推荐。

根据数据统计,大约 60% 的用户会通过分类来浏览商品。这说明分类功能是一个很重要的功能。

分类系统的架构设计

一个好的分类系统应该包含以下几个层级:

一级分类 - 最顶层的分类,比如 “数码电子”、“服装”、“食品” 等。

二级分类 - 一级分类下的子分类,比如 “数码电子” 下的 “手机”、“电脑” 等。

三级分类 - 二级分类下的子分类,比如 “手机” 下的 “苹果”、“三星” 等。

这个多层级的结构可以让用户逐步缩小搜索范围,最终找到他们想要的商品。

分类列表页面的实现

让我们先看一下如何实现分类列表页面。首先定义分类列表页面的 Widget 类:

class CategoriesPage extends StatelessWidget {
  const CategoriesPage({super.key});

  
  Widget build(BuildContext context) {
    final categories = [
      {'name': '数码电子', 'icon': Icons.devices, 'count': 156},
      {'name': '珠宝首饰', 'icon': Icons.diamond, 'count': 42},
      {'name': '男装', 'icon': Icons.checkroom, 'count': 89},
      {'name': '女装', 'icon': Icons.dry_cleaning, 'count': 124},

这里使用 StatelessWidget 而不是 StatefulWidget。这个选择很重要,因为分类列表通常不需要管理任何本地状态。所有的分类信息都是静态的,不会因为用户的操作而改变。

categories 是一个列表,包含了所有的分类信息。每个分类都有名称、图标和商品数量。这个列表可以从服务器获取,也可以硬编码在应用中。

分类数据的结构设计

      {'name': '数码电子', 'icon': Icons.devices, 'count': 156},

这里使用 Map 来存储分类信息。Map 是一个键值对的集合,这样可以方便地存储分类的各种属性。

name - 分类的名称,比如 “数码电子”。这个名称会显示给用户,所以应该清晰易懂。

icon - 分类的图标。使用 Material Design 的图标库中的图标。图标可以帮助用户快速识别分类。

count - 这个分类中的商品数量。这个数字可以帮助用户了解这个分类中有多少商品。比如,如果一个分类有 156 件商品,用户就知道这个分类中有很多商品可以选择。

使用 GridView 展示分类

接下来看看如何使用 GridView 来展示分类:

    return SimpleScaffoldPage(
      title: '分类',
      child: GridView.builder(
        padding: const EdgeInsets.all(16),

SimpleScaffoldPage 是一个自定义的 Scaffold 包装器,提供了统一的页面结构。

GridView.builder 是一个高效的网格视图组件。它只会构建可见的 Widget,不会构建屏幕外的 Widget。这样可以提升性能,特别是当分类很多时。

padding: const EdgeInsets.all(16) 添加了内边距,让内容不会紧贴屏幕边缘。

GridView 的配置

        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2, 
          mainAxisSpacing: 12, 
          crossAxisSpacing: 12, 
          childAspectRatio: 1.2
        ),

SliverGridDelegateWithFixedCrossAxisCount 定义了网格的布局。

crossAxisCount: 2 表示每行显示 2 个分类。这样可以充分利用屏幕空间。在宽屏设备上,可以考虑显示 3 个或更多。

mainAxisSpacing: 12 是行之间的间距。12 像素的间距看起来比较舒适。

crossAxisSpacing: 12 是列之间的间距。

childAspectRatio: 1.2 是每个分类卡片的宽高比。1.2 表示宽度是高度的 1.2 倍。这样卡片看起来比较宽,有足够的空间显示分类名称。

构建分类卡片

        itemBuilder: (context, index) {
          final cat = categories[index];
          return InkWell(
            borderRadius: BorderRadius.circular(14),
            onTap: () => Navigator.of(context).pushNamed(
              AppRoutes.categoryDetail, 
              arguments: cat['name']
            ),

itemBuilder 是一个回调函数,用来构建每个分类卡片。

InkWell 是一个可点击的组件。当用户点击时,会显示一个涟漪效果。这个效果可以给用户反馈,让用户知道他们点击了什么。

borderRadius: BorderRadius.circular(14) 让涟漪效果的边角是圆形的。这样涟漪效果看起来更自然。

onTap 回调在用户点击时触发。这里使用 Navigator.pushNamed() 来导航到分类详情页面,并传递分类名称作为参数。

分类卡片的内容

            child: ShopCard(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(cat['icon'] as IconData, size: 40, color: Theme.of(context).colorScheme.primary),
                  const SizedBox(height: 12),

ShopCard 是一个自定义的卡片组件,提供了统一的视觉风格。它通常包含阴影、圆角等效果。

Column 用来垂直排列分类的信息。

mainAxisAlignment: MainAxisAlignment.center 让内容在卡片中居中显示。这样看起来更美观。

Icon 显示分类的图标。图标的大小是 40,颜色使用了主题的主色。这样可以保持应用的视觉一致性。

                  Text(cat['name'] as String, style: Theme.of(context).textTheme.titleSmall, textAlign: TextAlign.center),
                  const SizedBox(height: 4),
                  Text('${cat['count']}件商品', style: Theme.of(context).textTheme.bodySmall),

Text(cat[‘name’]) 显示分类的名称。使用 titleSmall 样式让名称看起来比较突出。

textAlign: TextAlign.center 让文字居中显示。

Text(‘${cat[‘count’]}件商品’) 显示这个分类中的商品数量。使用 bodySmall 样式让这个文字看起来比较小,不会抢占太多的视觉空间。

动态获取分类数据

在实际项目中,分类数据应该从服务器获取,而不是硬编码。这样可以动态更新分类,不需要重新发布应用。可以改为使用 StatefulWidget 并从服务器获取数据:

首先,定义一个 Category 模型:

class Category {
  final String id;
  final String name;
  final IconData icon;
  final int count;

  Category({
    required this.id,
    required this.name,
    required this.icon,
    required this.count,
  });

  factory Category.fromJson(Map<String, dynamic> json) {
    return Category(
      id: json['id'],
      name: json['name'],
      icon: _getIconFromString(json['icon']),
      count: json['count'],
    );
  }

  static IconData _getIconFromString(String iconName) {
    // 根据字符串返回对应的图标
    // 这是一个简化的实现
    return Icons.category;
  }
}

然后,改为使用 StatefulWidget:

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

  
  State<CategoriesPage> createState() => _CategoriesPageState();
}

class _CategoriesPageState extends State<CategoriesPage> {
  late Future<List<Category>> _categoriesFuture;

  
  void initState() {
    super.initState();
    _categoriesFuture = _fetchCategories();
  }

initState 是 StatefulWidget 的生命周期方法,在 Widget 创建时调用一次。这是加载初始数据的最佳时机。

  Future<List<Category>> _fetchCategories() async {
    try {
      // 从服务器获取分类数据
      return await _api.getCategories();
    } catch (e) {
      print('Failed to fetch categories: $e');
      rethrow;
    }
  }

_fetchCategories() 是一个异步方法,它会从服务器获取分类数据。使用 try-catch 块来处理可能的错误。

  
  Widget build(BuildContext context) {
    return SimpleScaffoldPage(
      title: '分类',
      child: FutureBuilder<List<Category>>(
        future: _categoriesFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

FutureBuilder 用来处理异步操作。当数据还在加载时,显示一个加载指示器。

          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error_outline, size: 48, color: Colors.red),
                  const SizedBox(height: 16),
                  Text('加载失败: ${snapshot.error}'),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => setState(() {
                      _categoriesFuture = _fetchCategories();
                    }),
                    child: const Text('重试'),
                  ),
                ],
              ),
            );
          }

如果加载失败,显示一个错误提示和重试按钮。这样用户可以在网络连接恢复后重新加载数据。

          final categories = snapshot.data ?? [];
          
          if (categories.isEmpty) {
            return const Center(child: Text('暂无分类'));
          }
          
          return GridView.builder(
            padding: const EdgeInsets.all(16),
            gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: 2,
              mainAxisSpacing: 12,
              crossAxisSpacing: 12,
              childAspectRatio: 1.2,
            ),
            itemCount: categories.length,
            itemBuilder: (context, index) {
              final category = categories[index];
              return InkWell(
                borderRadius: BorderRadius.circular(14),
                onTap: () => Navigator.of(context).pushNamed(
                  AppRoutes.categoryDetail,
                  arguments: category.name,
                ),
                child: ShopCard(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Icon(category.icon, size: 40, color: Theme.of(context).colorScheme.primary),
                      const SizedBox(height: 12),
                      Text(category.name, style: Theme.of(context).textTheme.titleSmall, textAlign: TextAlign.center),
                      const SizedBox(height: 4),
                      Text('${category.count}件商品', style: Theme.of(context).textTheme.bodySmall),
                    ],
                  ),
                ),
              );
            },
          );
        },
      ),
    );
  }
}

这样可以动态获取分类数据,当分类更新时,用户会看到最新的内容。

分类详情页面的实现

分类详情页面显示了某个分类中的所有商品。用户可以在这个页面中进行排序和筛选,找到他们想要的商品。

分类详情页面的基础结构

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

  
  State<CategoryDetailPage> createState() => _CategoryDetailPageState();
}

class _CategoryDetailPageState extends State<CategoryDetailPage> {
  String _sortBy = 'popular';
  RangeValues _priceRange = const RangeValues(0, 500);

这里使用 StatefulWidget 因为分类详情页面需要管理排序和筛选的状态。当用户改变排序方式或价格范围时,页面需要重新构建。

_sortBy 存储当前的排序方式。默认值是 ‘popular’,表示按综合排序。这是大多数用户的首选。

_priceRange 存储当前的价格范围。默认范围是 0 到 500。这个范围应该根据实际的商品价格来调整。

获取分类名称

  
  Widget build(BuildContext context) {
    final categoryName = ModalRoute.of(context)?.settings.arguments as String? ?? '分类';

    return SimpleScaffoldPage(
      title: categoryName,

ModalRoute.of(context)?.settings.arguments 用来获取从分类列表页面传递过来的分类名称。

?? ‘分类’ 是一个默认值。如果没有传递分类名称,就使用 ‘分类’ 作为默认值。这样可以避免应用崩溃。

页面的操作按钮

      actions: [
        IconButton(
          icon: const Icon(Icons.filter_list), 
          onPressed: () => _showFilterSheet(context)
        ),
        IconButton(
          icon: const Icon(Icons.search), 
          onPressed: () => Navigator.of(context).pushNamed(AppRoutes.search)
        ),
      ],

actions 是 AppBar 中的操作按钮。这些按钮通常用来执行一些快速操作。

Icons.filter_list 是筛选按钮。点击时会显示筛选面板,让用户可以根据价格、评分等条件来筛选商品。

Icons.search 是搜索按钮。点击时会导航到搜索页面,让用户可以搜索特定的商品。

排序功能的实现

排序功能让用户可以按不同的方式来排列商品。这对于帮助用户找到他们想要的商品很重要。

      child: Column(
        children: [
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
            child: Row(
              children: [
                const Text('排序:'),
                DropdownButton<String>(
                  value: _sortBy,
                  underline: const SizedBox(),

Container 用来包装排序选项。添加了内边距,让内容不会紧贴屏幕边缘。

Row 用来水平排列排序标签和下拉菜单。

DropdownButton 是一个下拉菜单组件。用户可以从中选择排序方式。

underline: const SizedBox() 移除了下拉菜单下面的下划线。这样看起来更简洁。

排序选项

                  items: const [
                    DropdownMenuItem(value: 'popular', child: Text('综合')),
                    DropdownMenuItem(value: 'newest', child: Text('最新')),
                    DropdownMenuItem(value: 'price_low', child: Text('价格从低到高')),
                    DropdownMenuItem(value: 'price_high', child: Text('价格从高到低')),
                    DropdownMenuItem(value: 'rating', child: Text('评分')),
                  ],
                  onChanged: (v) => setState(() => _sortBy = v!),

items 定义了所有可用的排序方式。每个排序方式都有一个值和一个显示的文本。

综合 - 按综合排序,通常是按热度或销量排序。这是最常见的排序方式。

最新 - 按上架时间排序,最新的商品排在前面。这对于想要了解最新商品的用户很有用。

价格从低到高 - 按价格升序排序。这对于想要找到便宜商品的用户很有用。

价格从高到低 - 按价格降序排序。这对于想要找到高端商品的用户很有用。

评分 - 按用户评分排序。这对于想要找到高质量商品的用户很有用。

onChanged 回调在用户选择排序方式时触发。这里调用 setState() 来更新排序方式,然后页面会重新构建,显示按新的排序方式排列的商品。

商品网格的实现

          Expanded(
            child: GridView.builder(
              padding: const EdgeInsets.all(16),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2, 
                mainAxisSpacing: 12, 
                crossAxisSpacing: 12, 
                childAspectRatio: 0.7
              ),
              itemCount: 12,

Expanded 用来让商品网格占据剩余的屏幕空间。这样排序选项会固定在顶部,商品网格会占据下面的所有空间。

GridView.builder 用来显示商品列表。

childAspectRatio: 0.7 表示商品卡片的宽高比是 0.7,即高度比宽度更大。这样可以为商品信息留出更多的空间。

商品卡片的结构

              itemBuilder: (context, index) {
                return ShopCard(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Expanded(
                        child: Container(
                          decoration: BoxDecoration(
                            color: Colors.grey.shade200, 
                            borderRadius: BorderRadius.circular(8)
                          ),
                          child: const Center(
                            child: Icon(Icons.image, size: 40, color: Colors.grey)
                          ),
                        ),
                      ),

ShopCard 是一个自定义的卡片组件。

Column 用来垂直排列商品的图片和信息。

crossAxisAlignment: CrossAxisAlignment.start 让内容从左边开始对齐。

Expanded 用来让商品图片占据卡片的大部分空间。

Container 用来显示商品图片的占位符。在实际项目中,这里应该显示真实的商品图片。

BoxDecoration 定义了容器的样式,包括背景颜色和圆角。

商品信息的显示

                      const SizedBox(height: 8),
                      Text('商品 ${index + 1}', maxLines: 2, overflow: TextOverflow.ellipsis),
                      const SizedBox(height: 4),
                      Text(${(19.99 + index * 10).toStringAsFixed(2)}', 
                        style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.red)
                      ),

Text(‘商品 ${index + 1}’) 显示商品的名称。

maxLines: 2 限制商品名称最多显示 2 行。如果名称太长,就会被截断。

overflow: TextOverflow.ellipsis 如果商品名称超过 2 行,就用省略号表示。这样可以避免文字溢出。

Text(‘¥…’) 显示商品的价格。价格用红色显示,这是电商应用的常见做法。红色可以吸引用户的注意力。

fontWeight: FontWeight.bold 让价格看起来更突出。

商品评分的显示

                      Row(
                        children: [
                          const Icon(Icons.star, size: 14, color: Colors.amber), 
                          Text(' ${(4.0 + index * 0.1).toStringAsFixed(1)}', 
                            style: Theme.of(context).textTheme.bodySmall
                          )
                        ]
                      ),

Row 用来水平排列评分图标和评分数字。

Icon(Icons.star) 显示一个星形图标,表示评分。

Text 显示评分数字。比如 “4.5” 表示 4.5 分。

bodySmall 样式让评分看起来比较小,不会抢占太多的视觉空间。

筛选功能的实现

筛选功能让用户可以根据价格、评分等条件来筛选商品。这对于帮助用户快速找到他们想要的商品很重要。

显示筛选面板

  void _showFilterSheet(BuildContext context) {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      builder: (context) => StatefulBuilder(
        builder: (context, setSheetState) => DraggableScrollableSheet(
          initialChildSize: 0.6,
          minChildSize: 0.4,
          maxChildSize: 0.9,
          expand: false,

showModalBottomSheet 显示一个从底部弹出的面板。这是一个很常见的 UI 模式,用来显示额外的选项或信息。

isScrollControlled: true 让面板可以占据整个屏幕(除了 AppBar)。这样可以显示更多的筛选选项。

StatefulBuilder 用来在面板中管理本地状态。这样面板中的状态改变不会影响页面的状态。

DraggableScrollableSheet 让面板可以拖动调整大小。用户可以向上拖动面板来扩大它,或向下拖动来缩小它。

initialChildSize: 0.6 表示面板初始占据屏幕的 60%。这是一个合理的默认值。

minChildSize: 0.4 表示面板最小占据屏幕的 40%。用户不能把面板拖得太小。

maxChildSize: 0.9 表示面板最大占据屏幕的 90%。用户不能把面板拖得太大,这样可以保留一些空间来关闭面板。

筛选面板的标题和重置按钮

            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text('筛选', style: Theme.of(context).textTheme.titleLarge),
                  TextButton(
                    onPressed: () => setSheetState(() => _priceRange = const RangeValues(0, 500)), 
                    child: const Text('重置')
                  ),
                ],
              ),

Row 用来水平排列标题和重置按钮。

mainAxisAlignment: MainAxisAlignment.spaceBetween 让标题和重置按钮分别显示在两端。这样可以充分利用屏幕空间。

Text(‘筛选’) 是面板的标题。使用 titleLarge 样式让标题看起来比较突出。

TextButton 是一个文本按钮。点击时会重置筛选条件。这对于用户想要清除所有筛选条件很有用。

价格范围筛选

              const SizedBox(height: 24),
              Text('价格区间', style: Theme.of(context).textTheme.titleMedium),
              RangeSlider(
                values: _priceRange,
                min: 0,
                max: 500,
                divisions: 50,
                labels: RangeLabels(${_priceRange.start.round()}', ${_priceRange.end.round()}'),

RangeSlider 是一个范围滑块组件。用户可以通过拖动滑块来选择价格范围。

values: _priceRange 是当前的价格范围。

min: 0, max: 500 是价格范围的最小值和最大值。这些值应该根据实际的商品价格来调整。

divisions: 50 表示滑块被分成 50 个部分。这样用户可以更精确地选择价格。如果 divisions 太小,用户可能无法选择他们想要的价格。

labels 显示当前选择的价格范围。这样用户可以看到他们选择的具体价格。

                onChanged: (v) { 
                  setSheetState(() => _priceRange = v); 
                  setState(() => _priceRange = v); 
                },

onChanged 回调在用户改变价格范围时触发。这里需要同时调用 setSheetState()setState() 来更新面板和页面的状态。

setSheetState() 更新面板中的状态,这样滑块会立即响应用户的操作。

setState() 更新页面的状态,这样当用户关闭面板时,页面会显示按新的价格范围筛选的商品。

评分筛选

              const SizedBox(height: 24),
              Text('评分', style: Theme.of(context).textTheme.titleMedium),
              const SizedBox(height: 8),
              Wrap(
                spacing: 8,
                children: [4, 3, 2, 1].map((rating) => FilterChip(
                      label: Row(
                        mainAxisSize: MainAxisSize.min, 
                        children: [
                          Text('$rating'), 
                          const Icon(Icons.star, size: 14, color: Colors.amber), 
                          const Text('分以上')
                        ]
                      ),
                      selected: false,
                      onSelected: (v) {},
                    )).toList(),
              ),

Wrap 用来水平排列评分筛选选项。如果空间不足,会自动换行。这样可以在小屏幕上也能显示所有的选项。

FilterChip 是一个筛选芯片组件。用户可以点击来选择评分。

spacing: 8 是芯片之间的间距。

[4, 3, 2, 1] 定义了所有可用的评分选项。这些选项表示 “4 分以上”、“3 分以上” 等。

Row 用来显示评分选项的内容。包括评分数字、星形图标和 “分以上” 的文字。

确定按钮

              const SizedBox(height: 24),
              ShopButton(label: '确定', icon: Icons.check, onPressed: () => Navigator.of(context).pop()),

ShopButton 是一个自定义的按钮组件。点击时会关闭筛选面板。

label: ‘确定’ 是按钮的文字。

icon: Icons.check 是按钮的图标。

onPressed 回调在用户点击按钮时触发。这里调用 Navigator.of(context).pop() 来关闭筛选面板。

分类系统的最佳实践

1. 合理的分类层级

分类层级不应该太深。通常 2-3 层就足够了。如果层级太深,用户会感到困惑。

2. 清晰的分类名称

分类名称应该清晰易懂。用户应该能够快速理解每个分类的含义。

3. 使用合适的图标

每个分类都应该有一个合适的图标。图标可以帮助用户快速识别分类。

4. 显示商品数量

显示每个分类中的商品数量可以帮助用户了解这个分类中有多少商品。

5. 支持多种排序方式

提供多种排序方式可以让用户找到他们想要的商品。常见的排序方式包括综合、最新、价格、评分等。

6. 提供灵活的筛选选项

提供灵活的筛选选项可以让用户快速缩小搜索范围。常见的筛选选项包括价格、评分、品牌等。

高级筛选功能的实现

除了基本的价格和评分筛选,还可以实现更多高级的筛选功能。

品牌筛选

List<String> _selectedBrands = [];

void _showBrandFilter() {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('选择品牌'),
      content: SingleChildScrollView(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: ['苹果', '三星', '华为', '小米', '荣耀'].map((brand) => 
            CheckboxListTile(
              title: Text(brand),
              value: _selectedBrands.contains(brand),
              onChanged: (selected) {
                setState(() {
                  if (selected == true) {
                    _selectedBrands.add(brand);
                  } else {
                    _selectedBrands.remove(brand);
                  }
                });
              },
            )
          ).toList(),
        ),
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('确定'),
        ),
      ],
    ),
  );
}

这个方法实现了品牌筛选功能。用户可以选择一个或多个品牌来筛选商品。

CheckboxListTile 是一个复选框列表项。用户可以点击来选择或取消选择品牌。

_selectedBrands 存储了用户选择的品牌。

库存状态筛选

bool _showOnlyInStock = false;

void _toggleStockFilter() {
  setState(() {
    _showOnlyInStock = !_showOnlyInStock;
  });
}

这个方法实现了库存状态筛选。用户可以选择只显示有货的商品。

分类数据的缓存策略

本地缓存实现

class CategoryCache {
  static final Map<String, List<Category>> _cache = {};
  static final Map<String, DateTime> _cacheTime = {};
  static const Duration _cacheDuration = Duration(hours: 1);

  static List<Category>? get(String key) {
    if (!_cache.containsKey(key)) {
      return null;
    }

    final cacheTime = _cacheTime[key];
    if (cacheTime != null && 
        DateTime.now().difference(cacheTime) > _cacheDuration) {
      _cache.remove(key);
      _cacheTime.remove(key);
      return null;
    }

    return _cache[key];
  }

  static void set(String key, List<Category> categories) {
    _cache[key] = categories;
    _cacheTime[key] = DateTime.now();
  }

  static void clear() {
    _cache.clear();
    _cacheTime.clear();
  }
}

这个类实现了一个简单的缓存机制。

_cache 存储了缓存的分类数据。

_cacheTime 存储了每个缓存的时间。

_cacheDuration 定义了缓存的有效期。超过这个时间的缓存会被删除。

get() 方法获取缓存的数据。如果缓存已过期,就返回 null。

set() 方法保存数据到缓存。

使用缓存

Future<List<Category>> _fetchCategories() async {
  // 先检查缓存
  final cached = CategoryCache.get('categories');
  if (cached != null) {
    return cached;
  }

  try {
    // 从服务器获取数据
    final categories = await _api.getCategories();
    
    // 保存到缓存
    CategoryCache.set('categories', categories);
    
    return categories;
  } catch (e) {
    print('Failed to fetch categories: $e');
    rethrow;
  }
}

这个方法在获取分类数据时,先检查缓存。如果缓存存在且未过期,就直接返回缓存的数据。否则,从服务器获取数据并保存到缓存。

分类数据的本地存储

使用 SharedPreferences 存储分类数据

Future<void> _saveCategoriesLocally(List<Category> categories) async {
  final prefs = await SharedPreferences.getInstance();
  
  // 将分类数据转换为 JSON 字符串
  final jsonString = jsonEncode(
    categories.map((c) => c.toJson()).toList()
  );
  
  // 保存到本地存储
  await prefs.setString('categories', jsonString);
}

Future<List<Category>> _loadCategoriesLocally() async {
  final prefs = await SharedPreferences.getInstance();
  
  // 从本地存储获取数据
  final jsonString = prefs.getString('categories');
  
  if (jsonString == null) {
    return [];
  }
  
  // 将 JSON 字符串转换为分类对象
  final jsonList = jsonDecode(jsonString) as List;
  return jsonList.map((json) => Category.fromJson(json)).toList();
}

这些方法实现了分类数据的本地存储。

_saveCategoriesLocally() 将分类数据保存到本地存储。

_loadCategoriesLocally() 从本地存储加载分类数据。

性能优化建议

1. 使用 GridView.builder

使用 GridView.builder 而不是 GridView 可以提升性能。GridView.builder 只会构建可见的 Widget。

为什么这很重要? 如果有 100 个分类,使用 GridView 会一次性构建所有 100 个 Widget。这会消耗大量的内存和 CPU。而 GridView.builder 只会构建屏幕上可见的 Widget,比如 6 个。当用户滚动时,不可见的 Widget 会被销毁,新的 Widget 会被构建。这样可以大大减少内存消耗。

2. 缓存分类数据

分类数据通常不会经常改变。可以将分类数据缓存到本地,避免频繁的网络请求。

缓存的好处:

  • 减少网络请求 - 不需要每次都从服务器获取数据
  • 提升加载速度 - 本地缓存的数据加载速度很快
  • 节省流量 - 减少网络流量消耗
  • 离线支持 - 用户可以在离线状态下查看缓存的数据

3. 图片懒加载

商品图片应该使用懒加载。只有当图片进入可见区域时,才加载图片。

class LazyImage extends StatefulWidget {
  final String imageUrl;
  final double width;
  final double height;

  const LazyImage({
    required this.imageUrl,
    required this.width,
    required this.height,
  });

  
  State<LazyImage> createState() => _LazyImageState();
}

class _LazyImageState extends State<LazyImage> {
  bool _isVisible = false;

  
  Widget build(BuildContext context) {
    return Visibility(
      visible: _isVisible,
      replacement: Container(
        width: widget.width,
        height: widget.height,
        color: Colors.grey.shade200,
        child: const Icon(Icons.image, color: Colors.grey),
      ),
      child: Image.network(
        widget.imageUrl,
        width: widget.width,
        height: widget.height,
        fit: BoxFit.cover,
      ),
    );
  }
}

这个组件实现了图片的懒加载。当图片进入可见区域时,才加载图片。

4. 分页加载商品

如果商品很多,应该使用分页加载。这样可以减少初始加载时间。

class PaginatedProductList extends StatefulWidget {
  final String categoryId;

  const PaginatedProductList({required this.categoryId});

  
  State<PaginatedProductList> createState() => _PaginatedProductListState();
}

class _PaginatedProductListState extends State<PaginatedProductList> {
  int _currentPage = 1;
  final int _pageSize = 20;
  List<Product> _products = [];
  bool _isLoading = false;
  bool _hasMore = true;

  
  void initState() {
    super.initState();
    _loadMore();
  }

  Future<void> _loadMore() async {
    if (_isLoading || !_hasMore) return;

    setState(() => _isLoading = true);

    try {
      final newProducts = await _api.getProducts(
        categoryId: widget.categoryId,
        page: _currentPage,
        pageSize: _pageSize,
      );

      setState(() {
        _products.addAll(newProducts);
        _currentPage++;
        _hasMore = newProducts.length == _pageSize;
        _isLoading = false;
      });
    } catch (e) {
      setState(() => _isLoading = false);
      print('Failed to load products: $e');
    }
  }

  
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: _products.length + (_hasMore ? 1 : 0),
      itemBuilder: (context, index) {
        if (index == _products.length) {
          if (_isLoading) {
            return const Center(child: CircularProgressIndicator());
          } else if (_hasMore) {
            _loadMore();
            return const SizedBox.shrink();
          }
          return const SizedBox.shrink();
        }

        return ProductTile(product: _products[index]);
      },
    );
  }
}

这个组件实现了分页加载。当用户滚动到列表底部时,会自动加载下一页的商品。

总结

这篇文章实现了一个功能完整的商品分类系统,包括分类列表、分类详情、排序、筛选等功能。

商品分类是电商应用的核心功能。一个好的分类系统可以帮助用户快速找到他们想要的商品,提升用户体验。

代码都来自实际项目,可以直接运行。下一篇我们会实现商品详情页面,讲解如何展示商品的详细信息。


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

Logo

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

更多推荐