Flutter: Phase 1 综合练习 — ShopApp 入门版
学而不用,犹如买了食材不做菜——这一课把前 6 课的知识全部炒成一桌 ShopApp 大餐。
📋 前置知识:需要先掌握以下内容
- 第1课:Flutter 简介与环境搭建
- 第2课:Dart 语言速览
- 第3课:Widget 基础
- 第4课:布局系统
- 第5课:StatefulWidget 与交互
- 第6课:Material Design 与常用组件
1. 你将学到
- 项目结构规划:lib/ 下 models / widgets / screens 分层
- 商品数据模型 Product:id / name / price(USD) / imageUrl / rating
- 首页 GridView 展示 1000+ 商品卡片(模拟数据)
- 购物车页面:StatefulWidget 管理商品数量与总价计算
- 商品详情页:SliverAppBar + 规格选择 BottomSheet
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。
📖 小节
- 项目分层:models(数据)/ widgets(组件)/ screens(页面)/ data(数据源)
- Mock 数据快速验证 UI,后期替换为真实 API
- GridView.builder 懒加载支持千级商品列表
- 购物车用 StatefulWidget + setState 管理,回调传递数据
- SliverAppBar 实现沉浸式详情页体验
📝 作业
- 基础题(难度⭐):按教程结构创建项目,确保 HomePage 显示 20 个商品卡片。
- 进阶题(难度⭐⭐):在 HomePage 添加搜索功能:输入关键词过滤商品名称,用 setState 刷新列表。
- 挑战题(难度⭐⭐⭐):实现完整的三个页面跳转流程:首页 → 详情 → 购物车,购物车修改数量后返回首页时,AppBar 角标数字自动更新。