Flutter - 编写高级应用
Flutter - 构建一个进阶应用
Section titled “Flutter - 构建一个进阶应用”本章演示如何构建一个更完整的应用程序,一个“费用计算器”,它集成了多个概念:数据持久化(SQLite)、状态管理(Provider)、表单以及进阶列表视图。
- 显示费用列表。
- 显示总费用金额。
- 提供一个表单用于添加新费用。
- 允许编辑现有费用。
- 允许删除费用(例如,通过滑动)。
- 使用 SQLite 在本地持久化数据。
使用的关键技术:
Section titled “使用的关键技术:”ListView.separated用于显示带有分隔符的列表。Dismissible组件用于实现滑动删除功能。- Flutter 表单(
Form、TextFormField、验证)。 sqflite用于本地数据库存储。provider用于高效的状态管理(替换已过时的scoped_model)。intl用于日期格式化。
- 创建一个新的 Flutter 项目:
flutter create expense_calculator。 - 打开
pubspec.yaml并添加依赖项:
dependencies: flutter: sdk: flutter sqflite: ^2.3.0 # For SQLite database path_provider: ^2.1.1 # To find correct file paths provider: ^6.1.1 # For state management intl: ^0.18.1 # For date/number formatting path: ^1.8.3 # Path manipulation utilities- 运行
flutter pub get。 - 重要提示: 原始教程使用了
scoped_model。我们将使用provider进行现代化改造,这需要显著重构状态管理部分。
1. 数据模型 (lib/expense.dart)
Section titled “1. 数据模型 (lib/expense.dart)”import 'package:intl/intl.dart';
class Expense { final int? id; // 可空,用于自增主键 final double amount; final DateTime date; final String category;
Expense({ this.id, required this.amount, required this.date, required this.category, });
// 用于显示的计算属性 String get formattedDate { // 使用 intl 包进行可靠格式化 return DateFormat('yyyy-MM-dd').format(date); }
// 将 Expense 对象转换为 Map 用于数据库 Map<String, dynamic> toMap() { return { 'id': id, 'amount': amount, 'date': date.toIso8601String(), // 将日期存储为 ISO8601 字符串 'category': category, }; }
// 从 Map(来自数据库)创建 Expense 对象 factory Expense.fromMap(Map<String, dynamic> map) { return Expense( id: map['id'] as int?, amount: map['amount'] as double, date: DateTime.parse(map['date'] as String), category: map['category'] as String, ); }}2. 数据库助手 (lib/database_helper.dart)
Section titled “2. 数据库助手 (lib/database_helper.dart)”此类处理所有 SQLite 交互。(使用 ‘Flutter - 数据库概念’ 章中定义的 DatabaseHelper 类,调整表/列名以适应 Expenses)。
// --- 复用上一章的 DatabaseHelper ---// --- 确保表名为 'Expense' ---// --- 列:id (INTEGER PK AUTOINCREMENT), amount (REAL), date (TEXT), category (TEXT) ---
// DatabaseHelper 中的示例适配:
// const String _tableName = 'Expense'; // 修改表名
// Future<void> _onCreate(Database db, int version) async {// await db.execute('''// CREATE TABLE $_tableName (// id INTEGER PRIMARY KEY AUTOINCREMENT,// amount REAL NOT NULL,// date TEXT NOT NULL,// category TEXT NOT NULL// )// ''');// // 可选:插入初始数据// }
// // 方法需要使用 Expense 模型和正确的表名// Future<int> insertExpense(Expense expense) async {// final db = await database;// // 使用 expense.toMap(),确保如果 'id' 为 null 则排除,以便自动增长// Map<String, dynamic> row = {// 'amount': expense.amount,// 'date': expense.date.toIso8601String(),// 'category': expense.category// };// return await db.insert(_tableName, row);// }
// Future<List<Expense>> getAllExpenses() async {// final db = await database;// final List<Map<String, dynamic>> maps = await db.query(_tableName, orderBy: 'date DESC');// return List.generate(maps.length, (i) => Expense.fromMap(maps[i]));// }
// Future<int> updateExpense(Expense expense) async { ... }// Future<int> deleteExpense(int id) async { ... }// Future<double> getTotalExpense() async {// final db = await database;// final List<Map<String, dynamic>> result = await db.rawQuery(// "SELECT SUM(amount) as total FROM $_tableName"// );// return (result.first['total'] as double?) ?? 0.0;// }
// --- DatabaseHelper 示例适配结束 ---3. 使用 Provider 进行状态管理 (lib/expense_provider.dart)
Section titled “3. 使用 Provider 进行状态管理 (lib/expense_provider.dart)”创建一个 ChangeNotifier 来持有费用列表并通知监听者变化。
import 'package:flutter/foundation.dart';import 'dart:collection'; // 用于 UnmodifiableListViewimport 'database_helper.dart';import 'expense.dart';
class ExpenseProvider with ChangeNotifier { final DatabaseHelper _dbHelper = DatabaseHelper(); List<Expense> _items = []; double _totalExpense = 0.0;
// 提供费用列表的只读访问 UnmodifiableListView<Expense> get items => UnmodifiableListView(_items);
// 提供总计的只读访问 double get totalExpense => _totalExpense;
ExpenseProvider() { // 提供者创建时加载初始数据 loadExpenses(); }
// 从数据库加载费用 Future<void> loadExpenses() async { _items = await _dbHelper.getAllExpenses(); await _calculateTotal(); notifyListeners(); // 通知 UI 重建 }
// 计算总计(可优化) Future<void> _calculateTotal() async { _totalExpense = await _dbHelper.getTotalExpense(); // 或者,如果频繁更新时数据库调用较慢,则从 _items 计算: // _totalExpense = _items.fold(0.0, (sum, item) => sum + item.amount); }
// 添加新费用 Future<void> addExpense(Expense expense) async { await _dbHelper.insertExpense(expense); await loadExpenses(); // 从数据库重新加载数据以获取新 ID 并更新总计 // 对于大型数据集的优化,你可以在本地添加并重新计算总计 }
// 更新现有费用 Future<void> updateExpense(Expense expense) async { await _dbHelper.updateExpense(expense); await loadExpenses(); // 为简单起见重新加载所有数据 // 优化:如果需要,直接在 _items 列表中更新项 }
// 删除费用 Future<void> deleteExpense(int id) async { await _dbHelper.deleteExpense(id); _items.removeWhere((item) => item.id == id); // 先在本地移除以提高响应速度 await _calculateTotal(); // 重新计算总计 notifyListeners(); // 立即更新 UI // 可选:可以调用 loadExpenses() 以确保一致性 }
// 根据 ID 获取费用(用于编辑表单) Expense? getExpenseById(int id) { try { return _items.firstWhere((item) => item.id == id); } catch (e) { return null; // 未找到 } }}4. 主应用设置 (lib/main.dart)
Section titled “4. 主应用设置 (lib/main.dart)”使用 ChangeNotifierProvider 包装应用,使 ExpenseProvider 可用。
import 'package:flutter/material.dart';import 'package:provider/provider.dart';import 'expense_provider.dart';import 'home_page.dart'; // 接下来我们会创建这个
void main() { runApp(const MyApp());}
class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { // 向组件树提供 ExpenseProvider return ChangeNotifierProvider( create: (context) => ExpenseProvider(), child: MaterialApp( title: 'Expense Calculator', theme: ThemeData( primarySwatch: Colors.green, // 更改了主题颜色 visualDensity: VisualDensity.adaptivePlatformDensity, ), home: const HomePage(), ), ); }}5. 主页 UI (lib/home_page.dart)
Section titled “5. 主页 UI (lib/home_page.dart)”显示列表、总计,并提供导航到表单的功能。
import 'package:flutter/material.dart';import 'package:provider/provider.dart';import 'package:intl/intl.dart'; // 用于格式化货币import 'expense_provider.dart';import 'expense.dart';import 'form_page.dart'; // 接下来我们会创建这个
class HomePage extends StatelessWidget { const HomePage({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { // 使用 Consumer 或 context.watch 访问提供者 // 使用 Consumer 只会重建组件树的这一部分 return Scaffold( appBar: AppBar( title: const Text('Expense Calculator'), ), body: Consumer<ExpenseProvider>( builder: (context, expenseProvider, child) { // 如果提供者实现了加载状态,则处理它 if (expenseProvider.items.isEmpty) { // 可以显示加载指示器或“无费用”消息 // 检查是否真的为空或仍在初始加载 return const Center(child: Text('No expenses yet. Add one!')); } return ListView.separated( itemCount: expenseProvider.items.length + 1, // +1 用于总计行 itemBuilder: (context, index) { if (index == 0) { // 显示总费用行 final total = expenseProvider.totalExpense; final currencyFormat = NumberFormat.currency(locale: 'en_US', symbol: '$'); return ListTile( title: Text( 'Total Expenses: ${currencyFormat.format(total)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), ), ); } else { // 显示费用项行 final itemIndex = index - 1; final expense = expenseProvider.items[itemIndex]; return Dismissible( key: Key(expense.id.toString()), // Dismissible 的唯一键 direction: DismissDirection.endToStart, // 向左滑动删除 onDismissed: (direction) { // 使用提供者删除项 expenseProvider.deleteExpense(expense.id!); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('${expense.category} expense deleted')), ); }, background: Container( color: Colors.red, alignment: Alignment.centerRight, padding: const EdgeInsets.symmetric(horizontal: 20.0), child: const Icon(Icons.delete, color: Colors.white), ), child: ListTile( leading: const Icon(Icons.monetization_on), title: Text(expense.category), subtitle: Text('Spent on ${expense.formattedDate}'), trailing: Text(NumberFormat.currency(locale: 'en_US', symbol: '$').format(expense.amount)), onTap: () { // 导航到 FormPage 进行编辑 Navigator.push( context, MaterialPageRoute( builder: (context) => FormPage(expenseId: expense.id), ), ); }, ), ); } }, separatorBuilder: (context, index) { // 在项之间添加分隔符,但总计后不加 return index == 0 ? const SizedBox.shrink() : const Divider(); }, ); }, ), floatingActionButton: FloatingActionButton( onPressed: () { // 导航到 FormPage 以添加新费用 Navigator.push( context, MaterialPageRoute( builder: (context) => const FormPage(), // 没有 ID 表示“添加模式” ), ); }, tooltip: 'Add Expense', child: const Icon(Icons.add), ), ); }}6. 表单页 UI (lib/form_page.dart)
Section titled “6. 表单页 UI (lib/form_page.dart)”处理带有验证的费用添加和编辑。
import 'package:flutter/material.dart';import 'package:provider/provider.dart';import 'package:intl/intl.dart';import 'expense_provider.dart';import 'expense.dart';
class FormPage extends StatefulWidget { final int? expenseId; // 如果是添加则为 null,如果是编辑则非 null
const FormPage({Key? key, this.expenseId}) : super(key: key);
@override _FormPageState createState() => _FormPageState();}
class _FormPageState extends State<FormPage> { final _formKey = GlobalKey<FormState>(); final _amountController = TextEditingController(); final _categoryController = TextEditingController(); DateTime _selectedDate = DateTime.now();
bool _isEditMode = false; Expense? _existingExpense;
@override void initState() { super.initState(); if (widget.expenseId != null) { _isEditMode = true; // 使用 Provider 获取现有费用数据 // 这里使用 read 是因为在 initState 中我们不需要根据提供者变化进行重建 _existingExpense = Provider.of<ExpenseProvider>(context, listen: false) .getExpenseById(widget.expenseId!); if (_existingExpense != null) { _amountController.text = _existingExpense!.amount.toString(); _categoryController.text = _existingExpense!.category; _selectedDate = _existingExpense!.date; } else { // 处理未找到费用的情况(例如,导航返回) WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { // 检查组件是否仍在树中 Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Error: Expense not found')) ); } }); } } }
@override void dispose() { _amountController.dispose(); _categoryController.dispose(); super.dispose(); }
Future<void> _selectDate(BuildContext context) async { final DateTime? picked = await showDatePicker( context: context, initialDate: _selectedDate, firstDate: DateTime(2000), lastDate: DateTime(2101), ); if (picked != null && picked != _selectedDate) { setState(() { _selectedDate = picked; }); } }
void _submitForm() { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); // 触发 onSaved 回调
final amount = double.tryParse(_amountController.text) ?? 0.0; final category = _categoryController.text;
final expense = Expense( id: _isEditMode ? widget.expenseId : null, amount: amount, date: _selectedDate, category: category, );
// 访问提供者以添加或更新 final expenseProvider = Provider.of<ExpenseProvider>(context, listen: false); if (_isEditMode) { expenseProvider.updateExpense(expense); } else { expenseProvider.addExpense(expense); }
Navigator.pop(context); // 提交后返回 } }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(_isEditMode ? 'Edit Expense' : 'Add Expense'), ), body: Padding( padding: const EdgeInsets.all(16.0), child: Form( key: _formKey, child: ListView( // 使用 ListView 以在小屏幕上实现可滚动 children: <Widget>[ TextFormField( controller: _amountController, decoration: const InputDecoration( labelText: 'Amount', icon: Icon(Icons.attach_money), hintText: 'e.g., 19.99' ), keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (value) { if (value == null || value.isEmpty) { return 'Please enter an amount.'; } if (double.tryParse(value) == null) { return 'Please enter a valid number.'; } if (double.parse(value) <= 0) { return 'Amount must be positive.'; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _categoryController, decoration: const InputDecoration( labelText: 'Category', icon: Icon(Icons.category), hintText: 'e.g., Food, Transport' ), textCapitalization: TextCapitalization.words, validator: (value) { if (value == null || value.trim().isEmpty) { return 'Please enter a category.'; } return null; }, ), const SizedBox(height: 16), ListTile( leading: const Icon(Icons.calendar_today), title: const Text('Date'), subtitle: Text(DateFormat('yyyy-MM-dd').format(_selectedDate)), trailing: const Icon(Icons.edit), onTap: () => _selectDate(context), ), const SizedBox(height: 32), ElevatedButton( onPressed: _submitForm, child: Text(_isEditMode ? 'Update Expense' : 'Add Expense'), ), ], ), ), ), ); }}运行应用(flutter run)。
- 你应该会看到主屏幕,初始时为空或包含一些示例数据。
- 总费用会自动更新。
- 点击 ’+’ 按钮通过表单添加新费用。
- 点击现有费用进行编辑。
- 向左滑动费用即可删除。
- 得益于 SQLite,数据在应用重启后依然存在。
这个例子展示了如何结合 UI 元素、表单、本地持久化以及现代状态管理(provider)来构建一个功能完善的 Flutter 应用。