Skip to content

Flutter - 编写高级应用

本章演示如何构建一个更完整的应用程序,一个“费用计算器”,它集成了多个概念:数据持久化(SQLite)、状态管理(Provider)、表单以及进阶列表视图。

  • 显示费用列表。
  • 显示总费用金额。
  • 提供一个表单用于添加新费用。
  • 允许编辑现有费用。
  • 允许删除费用(例如,通过滑动)。
  • 使用 SQLite 在本地持久化数据。
  • ListView.separated 用于显示带有分隔符的列表。
  • Dismissible 组件用于实现滑动删除功能。
  • Flutter 表单(Form、TextFormField、验证)。
  • sqflite 用于本地数据库存储。
  • provider 用于高效的状态管理(替换已过时的 scoped_model)。
  • intl 用于日期格式化。
  1. 创建一个新的 Flutter 项目:flutter create expense_calculator。
  2. 打开 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
  1. 运行 flutter pub get。
  2. 重要提示: 原始教程使用了 scoped_model。我们将使用 provider 进行现代化改造,这需要显著重构状态管理部分。
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'; // 用于 UnmodifiableListView
import '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; // 未找到
}
}
}

使用 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(),
),
);
}
}

显示列表、总计,并提供导航到表单的功能。

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),
),
);
}
}

处理带有验证的费用添加和编辑。

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 应用。