Flutter: StatefulWidget 与交互
State 是 Widget 的记忆——没有 State,界面就像失忆症患者,每次看到你都不认识。
📋 前置知识:需要先掌握以下内容
- 第3课:Widget 基础
1. 你将学到
- StatefulWidget 完整生命周期:createState → initState → build → dispose
setState触发重建机制与最小重建范围- 事件处理:GestureDetector、InkWell、onPressed 回调
- 输入交互:TextField、TextEditingController 文本监听
- ShopApp 购物车数量增减器实现
2. 一个交互困境的真实故事
(1) 痛点:点击无响应
Bob 在 ShopApp 购物车页面遇到了奇怪的 bug:用户点击"-"按钮,数量从 1 减到 0,但界面上还显示 1。他直接修改了变量 _count = _count - 1,却没有调用 setState——Widget 不知道状态变了,自然不会重建 UI。
(2) setState 的解法
StatefulWidget 的核心理念:改状态不等于改 UI。必须通过 setState 通知框架"我变了,请重建"。
DART
import 'package:flutter/material.dart';
// WRONG: Direct mutation, UI not updated
void _decrement() {
_count = _count - 1; // State changed, but UI doesn't know
}
// RIGHT: setState triggers rebuild
void _decrement() {
setState(() {
_count = _count - 1; // Framework rebuilds this widget
});
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(3) 收益:界面与状态始终同步
Bob 加上 setState 后,购物车数量实时更新,总价自动重算。从此他记住:改数据必调 setState。
3. StatefulWidget 生命周期
stateDiagram-v2
[*] --> createState
createState --> initState
initState --> didChangeDependencies
didChangeDependencies --> build
build --> didUpdateWidget: setState / parent rebuild
didUpdateWidget --> build
build --> deactivate: removed from tree
deactivate --> dispose
dispose --> [*]
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
(1) 生命周期回调
| 回调 | 触发时机 | 用途 |
|---|---|---|
createState |
Framework 创建 State | 初始化 State 对象 |
initState |
State 插入树时 | 一次性初始化(订阅、Controller) |
didChangeDependencies |
依赖的 InheritedWidget 变化 | 读取主题、语言等 |
build |
每次需要渲染 UI | 构建 Widget 树 |
didUpdateWidget |
父 Widget 重建时 | 对比新旧 Widget 做响应 |
setState |
开发者主动调用 | 标记脏,触发 rebuild |
deactivate |
State 从树中移除 | 临时移除(可重新插入) |
dispose |
State 永久销毁 | 释放资源、取消订阅 |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:生命周期追踪
DART
import 'package:flutter/material.dart';
class LifecycleTracker extends StatefulWidget {
const LifecycleTracker({super.key});
@override
State<LifecycleTracker> createState() => _LifecycleTrackerState();
}
class _LifecycleTrackerState extends State<LifecycleTracker> {
int _buildCount = 0;
@override
void initState() {
super.initState();
debugPrint('initState called');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('didChangeDependencies called');
}
@override
void didUpdateWidget(covariant LifecycleTracker oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('didUpdateWidget called');
}
@override
Widget build(BuildContext context) {
_buildCount++;
debugPrint('build #$_buildCount');
return Text('Build count: $_buildCount');
}
@override
void dispose() {
debugPrint('dispose called');
super.dispose();
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
4. setState 机制详解
(1) setState 工作流程
| 步骤 | 操作 |
|---|---|
| 1 | 调用 setState(fn) |
| 2 | 执行 fn,更新状态变量 |
| 3 | 标记 Element 为 dirty |
| 4 | 下一帧构建时,Framework 调用 build |
| 5 | 新旧 Widget 树 diff,更新 RenderObject |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:购物车数量增减器
DART
import 'package:flutter/material.dart';
class QuantitySelector extends StatefulWidget {
final int initialValue;
final ValueChanged<int> onChanged;
const QuantitySelector({
super.key,
this.initialValue = 1,
required this.onChanged,
});
@override
State<QuantitySelector> createState() => _QuantitySelectorState();
}
class _QuantitySelectorState extends State<QuantitySelector> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
@override
void didUpdateWidget(covariant QuantitySelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
_count = widget.initialValue;
}
}
void _increment() {
setState(() {
_count = (_count + 1).clamp(1, 99);
});
widget.onChanged(_count);
}
void _decrement() {
setState(() {
_count = (_count - 1).clamp(1, 99);
});
widget.onChanged(_count);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove), onPressed: _decrement),
Text('$_count', style: const TextStyle(fontSize: 18)),
IconButton(icon: const Icon(Icons.add), onPressed: _increment),
],
),
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
5. 事件处理
(1) GestureDetector vs InkWell
| 组件 | 点击效果 | 水波纹 | 适用场景 |
|---|---|---|---|
GestureDetector |
无视觉反馈 | 无 | 自定义手势(长按/拖拽/双击) |
InkWell |
Material 水波纹 | 有 | 卡片/列表项点击 |
IconButton |
图标 + 水波纹 | 有 | 工具栏操作 |
ElevatedButton |
按钮样式 | 有 | 主要操作 |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:GestureDetector 手势识别
DART
import 'package:flutter/material.dart';
// 简化类定义
class Product {
final String name;
final double price;
final String imageUrl;
final int id;
const Product({required this.name, required this.price, required this.imageUrl, required this.id});
}
GestureDetector(
onTap: () => debugPrint('Tapped'),
onDoubleTap: () => debugPrint('Double tapped'),
onLongPress: () => debugPrint('Long pressed'),
onHorizontalDragEnd: (details) {
// Swipe to add/remove from cart
if (details.primaryVelocity! < 0) {
addToCart(product); // Swipe left
} else {
removeFromCart(product); // Swipe right
}
},
child: ProductCard(product: 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/状态会因平台略有差异。
:InkWell 商品卡片点击
DART
import 'package:flutter/material.dart';
// Product类定义见上方
InkWell(
onTap: () => Navigator.pushNamed(context, '/product/${product.id}'),
borderRadius: BorderRadius.circular(12),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Image.network(product.imageUrl, width: 60, height: 60),
const SizedBox(width: 12),
Expanded(child: Text(product.name)),
Text('\$${product.price.toStringAsFixed(2)}'),
],
),
),
),
)
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
6. 输入交互:TextField
(1) TextEditingController
| 功能 | 方法 |
|---|---|
| 获取文本 | controller.text |
| 设置文本 | controller.text = 'new' |
| 监听变化 | controller.addListener(fn) |
| 选择文本 | controller.selection |
| 清空输入 | controller.clear() |
▶ 示例
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
:搜索框实现
DART
import 'package:flutter/material.dart';
class SearchBar extends StatefulWidget {
final ValueChanged<String> onSearch;
const SearchBar({super.key, required this.onSearch});
@override
State<SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<SearchBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(() {
widget.onSearch(_controller.text);
});
}
@override
void dispose() {
_controller.dispose(); // Important: prevent memory leak
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onSearch('');
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
7. 完整示例:ShopApp 购物车页面
DART
import 'package:flutter/material.dart';
class CartPage extends StatefulWidget {
const CartPage({super.key});
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
final List<CartItem> _items = [
CartItem(name: 'Pro Laptop', price: 1299.99, quantity: 1),
CartItem(name: 'Wireless Mouse', price: 29.99, quantity: 2),
CartItem(name: 'USB-C Hub', price: 49.99, quantity: 1),
];
double get _total => _items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
int get _itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
void _updateQuantity(int index, int newQty) {
setState(() {
if (newQty <= 0) {
_items.removeAt(index);
} else {
_items[index] = _items[index].copyWith(quantity: newQty);
}
});
}
void _removeItem(int index) {
setState(() => _items.removeAt(index));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Cart ($_itemCount items)')),
body: _items.isEmpty
? const Center(child: Text('Your cart is empty'))
: Column(
children: [
Expanded(
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final item = _items[index];
return ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(item.name),
subtitle: Text('\$${item.price.toStringAsFixed(2)} each'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity - 1)),
Text('${item.quantity}', style: const TextStyle(fontSize: 16)),
IconButton(icon: const Icon(Icons.add_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity + 1)),
IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index)),
],
),
);
},
),
),
// Total bar
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
const Text('Total', style: TextStyle(fontSize: 12)),
Text('\$${_total.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
]),
ElevatedButton(
onPressed: _items.isEmpty ? null : () {},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16)),
child: const Text('Checkout'),
),
],
),
),
],
),
);
}
}
class CartItem {
final String name;
final double price;
final int quantity;
const CartItem({required this.name, required this.price, required this.quantity});
CartItem copyWith({String? name, double? price, int? quantity}) =>
CartItem(name: name ?? this.name, price: price ?? this.price, quantity: quantity ?? this.quantity);
}
TEXT
📖 仅展示
> **输出:** 在本地 Flutter SDK(Flutter 3.x / Dart 3.x)运行。Piston 服务器未安装 Flutter,请在本机 `flutter run` 实操对照。实际 UI/状态会因平台略有差异。
❓ 常见问题
Q setState 在 initState 里能调用吗?
A 不能。initState 执行时 Widget 还没构建完成。如需初始化后更新,用
WidgetsBinding.instance.addPostFrameCallback。Q setState 的闭包里只写一个变量赋值,为什么也要用闭包?
A 闭包明确标记了哪些状态发生了变化,有助于框架优化。也方便调试时追踪状态变更。
Q GestureDetector 的 onTap 不生效?
A 检查 child 是否有吸收事件的手势竞争者(如 ListView 的滚动)。可用
behavior: HitTestBehavior.opaque 确保整个区域可点击。Q TextEditingController 什么时候 dispose?
A 在 State 的
dispose 方法中调用 controller.dispose(),否则会内存泄漏。Q 为什么 setState 后界面没变化?
A 检查是否在 setState 闭包外修改了变量。只有闭包内的修改才会触发重建。
Q 多个 setState 会多次 rebuild 吗?
A 不会。Flutter 在一帧内合并多个 setState,只 rebuild 一次。
📖 小节
- StatefulWidget 有完整生命周期:initState → build → dispose
- setState 是触发 UI 更新的唯一途径:改状态不调 setState,界面不会变
- GestureDetector 支持丰富手势,InkWell 提供 Material 水波纹反馈
- TextEditingController 管理输入框状态,必须在 dispose 中释放
- 购物车增减器是 setState + 回调传递的经典应用
📝 作业
- 基础题(难度⭐):创建一个计数器页面,包含"加/减/重置"三个按钮,数字居中显示。
- 进阶题(难度⭐⭐):实现一个搜索框,输入时实时过滤下方列表(用 setState + TextEditingController)。
- 挑战题(难度⭐⭐⭐):实现一个完整的购物车:支持增减数量、删除商品、自动计算总价,空状态显示"Cart is empty"提示。