Flutter: StatefulWidget 与交互

State 是 Widget 的记忆——没有 State,界面就像失忆症患者,每次看到你都不认识。

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

1. 你将学到


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 生命周期

100%
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 一次。

📖 小节


📝 作业

  1. 基础题(难度⭐):创建一个计数器页面,包含"加/减/重置"三个按钮,数字居中显示。
  2. 进阶题(难度⭐⭐):实现一个搜索框,输入时实时过滤下方列表(用 setState + TextEditingController)。
  3. 挑战题(难度⭐⭐⭐):实现一个完整的购物车:支持增减数量、删除商品、自动计算总价,空状态显示"Cart is empty"提示。

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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