Flutter: Phase 1 综合练习 — ShopApp 入门版

学而不用,犹如买了食材不做菜——这一课把前 6 课的知识全部炒成一桌 ShopApp 大餐。

📋 前置知识:需要先掌握以下内容

1. 你将学到


2. 一个从零到一的实战故事

(1) 痛点:知识碎片化

Bob 学完了前 6 课,能写 Widget、能做布局、能处理交互,但把这些知识组装成一个完整应用时却无从下手。文件怎么组织?数据怎么传递?页面怎么跳转?像拼图有了碎片却拼不出完整画面。

(2) 项目化的解法

通过构建 ShopApp 入门版,把碎片知识串成完整链路:数据模型 → 列表展示 → 交互操作 → 页面跳转 → 状态管理。

(3) 收益:从"会写组件"到"能做项目"

完成 ShopApp 入门版后,Bob 具备了独立构建完整 Flutter 应用的能力,后续 Phase 2 只需替换状态管理和网络层。


3. 项目结构规划

(1) 目录结构

TEXT 📖 仅展示
shop_app/
├── lib/
│   ├── main.dart              # App entry point
│   ├── models/
│   │   └── product.dart       # Product data model
│   ├── widgets/
│   │   ├── product_card.dart  # Reusable product card
│   │   └── quantity_selector.dart
│   ├── screens/
│   │   ├── home_page.dart     # Product grid
│   │   ├── detail_page.dart   # Product detail
│   │   └── cart_page.dart     # Shopping cart
│   └── data/
│       └── mock_products.dart # Mock data generator
├── pubspec.yaml
└── test/
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

(2) 分层原则

职责 依赖方向
models 纯数据定义,无 UI 无依赖
widgets 可复用 UI 组件 依赖 models
screens 页面级 Widget 依赖 models + widgets
data 数据提供(mock/API) 依赖 models
main 应用入口 + 路由 依赖 screens

4. 数据模型与 Mock 数据

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:Product 数据模型

DART
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final double rating;
  final int reviewCount;
  final String category;

  const Product({
    required this.id,
    required this.name,
    required this.price,
    required this.imageUrl,
    this.rating = 0.0,
    this.reviewCount = 0,
    this.category = 'General',
  });
}

此 Product 类将在本课所有代码块中使用。

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:Mock 数据生成器

DART
// Product类定义见上方

class MockProducts {
  static final List<String> _names = [
    'Pro Laptop 16"', 'Wireless Earbuds', 'Smart Watch Pro',
    'Cotton T-Shirt', 'Running Shoes', 'Backpack Ultra',
    'Coffee Maker', 'Desk Lamp LED', 'Yoga Mat Premium',
    'Novel Collection', 'Wireless Mouse', 'USB-C Hub',
  ];

  static final List<String> _categories = [
    'Electronics', 'Clothing', 'Home', 'Books', 'Sports',
  ];

  static List<Product> generate({int count = 1000}) {
    return List.generate(count, (i) => Product(
      id: i + 1,
      name: _names[i % _names.length],
      price: (9.99 + (i % 50) * 10).toDouble(),
      imageUrl: 'https://cdn.shopapp.com/products/item_${i + 1}.jpg',
      rating: (3.0 + (i % 20) * 0.1),
      reviewCount: (i % 5) * 100,
      category: _categories[i % _categories.length],
    ));
  }
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

5. 首页:商品网格

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:HomePage 实现

DART
import 'package:flutter/material.dart';

// Product类定义见上方

// 简化类定义
class CartItem {
  final Product product;
  final int quantity;
  const CartItem({required this.product, required this.quantity});
  CartItem copyWith({Product? product, int? quantity}) =>
      CartItem(product: product ?? this.product, quantity: quantity ?? this.quantity);
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final List<Product> _products = MockProducts.generate(count: 1000);
  String _selectedCategory = 'All';

  List<Product> get _filtered => _selectedCategory == 'All'
      ? _products
      : _products.where((p) => p.category == _selectedCategory).toList();

  @override
  Widget build(BuildContext context) {
    final categories = ['All', ...MockProducts._categories.toSet()];
    return Scaffold(
      appBar: AppBar(
        title: const Text('ShopApp'),
        actions: [
          Stack(
            alignment: Alignment.center,
            children: [
              IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () => _navigateToCart()),
              if (_cartItems.isNotEmpty)
                Positioned(right: 4, top: 4,
                  child: CircleAvatar(radius: 8, backgroundColor: Colors.red,
                    child: Text('${_cartItems.length}', style: const TextStyle(fontSize: 9, color: Colors.white)))),
            ],
          ),
        ],
      ),
      body: Column(
        children: [
          // Category filter chips
          SizedBox(
            height: 48,
            child: ListView(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 8),
              children: categories.map((cat) => Padding(
                padding: const EdgeInsets.symmetric(horizontal: 4),
                child: ChoiceChip(
                  label: Text(cat),
                  selected: cat == _selectedCategory,
                  onSelected: (_) => setState(() => _selectedCategory = cat),
                ),
              )).toList(),
            ),
          ),
          // Product grid
          Expanded(
            child: GridView.builder(
              padding: const EdgeInsets.all(8),
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                mainAxisSpacing: 8,
                crossAxisSpacing: 8,
                childAspectRatio: 0.68,
              ),
              itemCount: _filtered.length,
              itemBuilder: (context, index) => ProductCard(
                key: ValueKey(_filtered[index].id),
                product: _filtered[index],
                onAddToCart: () => _addToCart(_filtered[index]),
                onTap: () => _navigateToDetail(_filtered[index]),
              ),
            ),
          ),
        ],
      ),
    );
  }

  final List<CartItem> _cartItems = [];

  void _addToCart(Product product) {
    setState(() {
      final idx = _cartItems.indexWhere((c) => c.product.id == product.id);
      if (idx >= 0) {
        _cartItems[idx] = _cartItems[idx].copyWith(quantity: _cartItems[idx].quantity + 1);
      } else {
        _cartItems.add(CartItem(product: product, quantity: 1));
      }
    });
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('${product.name} added to cart'), duration: const Duration(seconds: 1)),
    );
  }

  void _navigateToCart() {
    Navigator.push(context, MaterialPageRoute(
      builder: (_) => CartPage(items: _cartItems, onUpdate: (items) => setState(() => _cartItems.clear()..addAll(items))),
    ));
  }

  void _navigateToDetail(Product product) {
    Navigator.push(context, MaterialPageRoute(
      builder: (_) => DetailPage(product: product, onAddToCart: () => _addToCart(product)),
    ));
  }
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

6. 购物车页面

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:CartPage 实现

DART
import 'package:flutter/material.dart';

// Product类定义见上方

class CartItem {
  final Product product;
  final int quantity;
  const CartItem({required this.product, required this.quantity});
  double get total => product.price * quantity;
  CartItem copyWith({Product? product, int? quantity}) =>
      CartItem(product: product ?? this.product, quantity: quantity ?? this.quantity);
}

class CartPage extends StatefulWidget {
  final List<CartItem> items;
  final ValueChanged<List<CartItem>> onUpdate;
  const CartPage({super.key, required this.items, required this.onUpdate});

  @override
  State<CartPage> createState() => _CartPageState();
}

class _CartPageState extends State<CartPage> {
  late List<CartItem> _items;

  @override
  void initState() {
    super.initState();
    _items = List.from(widget.items);
  }

  double get _total => _items.fold(0.0, (sum, item) => sum + item.total);

  void _updateQty(int index, int qty) {
    setState(() {
      if (qty <= 0) _items.removeAt(index);
      else _items[index] = _items[index].copyWith(quantity: qty);
    });
    widget.onUpdate(_items);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Cart (${_items.length})')),
      body: _items.isEmpty
          ? const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
              Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
              SizedBox(height: 16),
              Text('Your cart is empty', style: TextStyle(fontSize: 18, color: Colors.grey)),
            ]))
          : Column(children: [
              Expanded(child: ListView.separated(
                itemCount: _items.length,
                separatorBuilder: (_, __) => const Divider(),
                itemBuilder: (context, i) {
                  final item = _items[i];
                  return ListTile(
                    leading: Image.network(item.product.imageUrl, width: 50, height: 50, fit: BoxFit.cover),
                    title: Text(item.product.name),
                    subtitle: Text('\$${item.total.toStringAsFixed(2)}'),
                    trailing: Row(mainAxisSize: MainAxisSize.min, children: [
                      IconButton(icon: const Icon(Icons.remove), onPressed: () => _updateQty(i, item.quantity - 1)),
                      Text('${item.quantity}'),
                      IconButton(icon: const Icon(Icons.add), onPressed: () => _updateQty(i, item.quantity + 1)),
                    ]),
                  );
                },
              )),
              // Total bar
              Container(padding: const EdgeInsets.all(16), color: Colors.grey[100],
                child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  Text('Total: \$${_total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
                  FilledButton(onPressed: () {}, child: const Text('Checkout')),
                ])),
            ]),
    );
  }
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

7. 商品详情页

▶ 示例

TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

:DetailPage 实现

DART
import 'package:flutter/material.dart';

// Product类定义见上方

class DetailPage extends StatelessWidget {
  final Product product;
  final VoidCallback onAddToCart;
  const DetailPage({super.key, required this.product, required this.onAddToCart});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(slivers: [
        SliverAppBar(expandedHeight: 300, pinned: true,
          flexibleSpace: FlexibleSpaceBar(
            title: Text(product.name, style: const TextStyle(fontSize: 14, shadows: [Shadow(color: Colors.black54)])),
            background: Image.network(product.imageUrl, fit: BoxFit.cover),
          ),
        ),
        SliverToBoxAdapter(child: Padding(padding: const EdgeInsets.all(16), child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(product.category, style: TextStyle(color: Colors.grey[600])),
            const SizedBox(height: 4),
            Row(children: [
              Text('\$${product.price.toStringAsFixed(2)}', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)),
              const SizedBox(width: 8),
              Icon(Icons.star, color: Colors.amber[700], size: 16),
              Text(' ${product.rating} (${product.reviewCount})'),
            ]),
            const SizedBox(height: 16),
            const Text('Description', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
            const SizedBox(height: 8),
            const Text('Premium quality product. Free shipping on orders over \$50. USD settlement supported.'),
          ],
        ))),
      ]),
      bottomNavigationBar: SafeArea(child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
        Expanded(child: FilledButton.icon(onPressed: onAddToCart,
          icon: const Icon(Icons.shopping_cart), label: const Text('Add to Cart - \$${product.price}'))),
      ]))),
    );
  }
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

8. 完整示例:ShopApp 入门版 main.dart

DART
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'data/mock_products.dart';
import 'screens/home_page.dart';

void main() => runApp(const ShopAppStarter);

const ShopAppStarter = ShopApp();

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ShopApp Starter',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.blue,
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}
TEXT 📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。

❓ 常见问题

Q 项目文件太多,import 路径容易写错?
A 使用 VS Code 的相对路径自动补全。约定 models/ 以 model.dart 结尾,widgets/ 以组件名命名。
Q setState 传递购物车数据到另一个页面,返回后数据丢了?
A 当前用回调函数传递。Phase 2 会用 Riverpod 全局状态管理解决这个问题。
Q 1000 个商品 GridView 会卡吗?
A GridView.builder 懒加载,只渲染可见项,1000 项无压力。如果每项很复杂,考虑 itemExtent 优化。
Q mock 数据图片加载不出来?
A 示例中使用了占位 URL。实际开发可用 placeholder: AssetImage() 或 Icons 替代。
Q Navigator.push 跳转和返回怎么传参?
A 跳转时通过构造函数传参;返回时通过 Navigator.pop(context, result) 传回,用 await 接收。
Q Phase 1 的代码能直接演进到 Phase 2 吗?
A 可以。数据模型层和 Widget 层基本不变,只需把 setState 替换为 Riverpod Provider。

📖 小节


📝 作业

  1. 基础题(难度⭐):按教程结构创建项目,确保 HomePage 显示 20 个商品卡片。
  2. 进阶题(难度⭐⭐):在 HomePage 添加搜索功能:输入关键词过滤商品名称,用 setState 刷新列表。
  3. 挑战题(难度⭐⭐⭐):实现完整的三个页面跳转流程:首页 → 详情 → 购物车,购物车修改数量后返回首页时,AppBar 角标数字自动更新。

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏