Flutter三方库适配OpenHarmony【expense_tracker】消费记录器项目完整实战

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

expense_tracker 是一个基于 Flutter 的消费记录器项目,核心代码位于 lib/main.dart。项目默认提供 Coffee、Bus Ticket、Lunch、Book、Gym 五条支出记录,顶部用渐变卡片展示总支出金额,中间横向展示各分类汇总,底部用 ListView.builder 展示每一笔明细。用户可以通过右下角 FloatingActionButton 打开新增支出弹窗,输入标题、金额并选择分类后添加记录,也可以通过长按列表项删除记录。

这个项目适合讲解 Flutter 记账类工具应用在 OpenHarmony 上的适配过程。它覆盖了 数据模型设计列表派生统计fold 汇总计算Map 分类聚合Dialog 表单输入DropdownButtonFormField 分类选择横向统计卡片空状态展示长按删除交互

在这里插入图片描述

图片说明:本文围绕 Flutter 列表、弹窗、分类汇总和 OpenHarmony 承载工程展开,所有关键代码均来自 expense_tracker 的真实源码。

记账类应用的核心不只是金额相加,还要让用户清楚看到总额、分类、明细、日期和删除行为之间的关系。

一、项目背景与目标

1.1 项目定位

expense_tracker 是一个轻量消费记录工具。它没有数据库和后端服务,所有支出数据都保存在内存列表中。用户可以新增记录、查看总金额、查看分类汇总、浏览支出明细,并通过长按删除记录。

当前项目真实支持的功能包括:

  • 默认展示 5 条支出记录。
  • 每条支出包含标题、金额、分类和日期。
  • 未传入日期时默认使用 DateTime.now()
  • 顶部显示总支出金额。
  • 分类汇总横向展示。
  • 分类汇总卡片包含图标、金额和分类名。
  • 支持 Food、Transport、Shopping、Health、Entertainment、Bills、Other 七类。
  • 新增弹窗支持输入标题。
  • 新增弹窗支持输入金额。
  • 新增弹窗支持下拉选择分类。
  • 金额通过 double.tryParse 解析。
  • 标题非空且金额解析成功时才新增。
  • 支出列表展示分类图标、标题、分类、金额和日期。
  • 长按列表项删除记录。
  • 列表为空时显示空状态。

1.2 技术目标

本文围绕真实源码拆解以下内容:

  1. Flutter 应用入口和蓝色 Material 3 主题。
  2. Expense 模型如何保存标题、金额、分类和日期。
  3. _expenses 默认数据如何组织。
  4. _categoryIcons 如何映射分类和图标。
  5. _totalExpenses 如何通过 fold 计算总额。
  6. _categoryTotals 如何使用 Map 按分类聚合。
  7. _addExpense 如何通过弹窗新增记录。
  8. StatefulBuilder 如何管理弹窗分类选择状态。
  9. _deleteExpense 如何长按删除明细。
  10. OpenHarmony 侧如何验证输入、下拉、列表、统计和空状态。

1.3 核心实现速览

能力 当前实现 适配关注点
应用入口 runApp(const ExpenseTrackerApp()) 确认首屏加载
主题 ColorScheme.fromSeed(seedColor: Colors.blue) 确认蓝色 Material 3 样式
数据模型 Expense 确认标题、金额、分类、日期
默认数据 _expenses 确认 5 条初始记录
分类图标 _categoryIcons 确认分类与图标映射
总金额 _totalExpenses 确认 fold 汇总
分类汇总 _categoryTotals 确认 Map 聚合
新增记录 AlertDialog 确认输入和下拉
删除记录 onLongPress 确认长按删除
空状态 _expenses.isEmpty 确认列表为空提示

二、环境准备与工程结构

2.1 工程结构

项目保持 Flutter 标准结构,同时包含 OpenHarmony 平台工程。

文件或目录 作用
lib/main.dart 应用入口、支出模型、统计计算、弹窗和 UI
pubspec.yaml SDK 约束、Flutter 依赖和 Material 图标配置
analysis_options.yaml Flutter lint 规则
test/ Flutter 测试目录
ohos/ OpenHarmony 平台承载工程
README.md 项目说明文件

当前业务逻辑集中在 lib/main.dart,没有引入持久化插件、数据库或图表库。

2.2 依赖配置

项目使用 Dart SDK ^3.9.2,依赖 Flutter SDK。

environment:
  sdk: ^3.9.2

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

支出统计、分类聚合和表单交互全部由 Dart 与 Flutter Material 基础能力完成。

2.3 常用命令

flutter pub get
flutter analyze
flutter test
flutter run
命令 用途
flutter pub get 获取依赖
flutter analyze 执行静态分析
flutter test 执行测试
flutter run 在目标设备运行

OpenHarmony 调试时,还需要结合本地 Flutter OpenHarmony 工具链完成构建、安装和运行。

三、应用入口与主题配置

3.1 import 依赖

项目只引入 Flutter Material。

import 'package:flutter/material.dart';

material.dart 提供 MaterialAppScaffoldAppBarCardAlertDialogDropdownButtonFormFieldListTileFloatingActionButton 等组件。

3.2 main 函数

入口函数启动根组件。

void main() {
  runApp(const ExpenseTrackerApp());
}

3.3 ExpenseTrackerApp

根组件创建 MaterialApp

class ExpenseTrackerApp extends StatelessWidget {
  const ExpenseTrackerApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Expense Tracker',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const ExpenseTrackerHomePage(title: 'Expense Tracker'),
    );
  }
}

这段代码包含三个关键点:

  • 应用标题为 Expense Tracker
  • 使用蓝色作为主题种子色。
  • 首页为 ExpenseTrackerHomePage

四、Expense 数据模型

4.1 模型源码

项目定义了 Expense 模型。

class Expense {
  String title;
  double amount;
  String category;
  DateTime date;

  Expense({
    required this.title,
    required this.amount,
    required this.category,
    DateTime? date,
  }) : date = date ?? DateTime.now();
}

这个模型封装一笔支出的核心信息。

4.2 字段说明

字段 类型 作用
title String 支出标题
amount double 支出金额
category String 支出分类
date DateTime 支出日期

4.3 默认日期

构造函数允许外部传入日期,也可以不传。

DateTime? date,
}) : date = date ?? DateTime.now();

如果没有传入日期,就使用创建记录时的当前时间。这符合随手记账场景。

4.4 模型边界

当前模型字段是可变字段。

String title;
double amount;
String category;
DateTime date;

在当前单页 Demo 中问题不大。正式项目中可以使用 final 字段和 copyWith 方法,减少意外修改。

五、默认支出数据

5.1 _expenses 列表

页面默认有 5 条支出。

final List<Expense> _expenses = [
  Expense(title: 'Coffee', amount: 4.50, category: 'Food'),
  Expense(title: 'Bus Ticket', amount: 2.00, category: 'Transport'),
  Expense(title: 'Lunch', amount: 12.00, category: 'Food'),
  Expense(title: 'Book', amount: 15.00, category: 'Shopping'),
  Expense(title: 'Gym', amount: 30.00, category: 'Health'),
];

5.2 默认数据表

标题 金额 分类
Coffee 4.50 Food
Bus Ticket 2.00 Transport
Lunch 12.00 Food
Book 15.00 Shopping
Gym 30.00 Health

默认总金额为 63.50。

5.3 默认分类分布

分类 金额
Food 16.50
Transport 2.00
Shopping 15.00
Health 30.00

Entertainment、Bills、Other 在初始数据中没有记录,但新增弹窗可以选择这些分类。

六、分类图标映射

6.1 _categoryIcons

项目使用 Map<String, IconData> 维护分类图标。

final Map<String, IconData> _categoryIcons = {
  'Food': Icons.restaurant,
  'Transport': Icons.directions_car,
  'Shopping': Icons.shopping_bag,
  'Health': Icons.local_hospital,
  'Entertainment': Icons.movie,
  'Bills': Icons.receipt,
  'Other': Icons.more_horiz,
};

6.2 分类表

分类 图标
Food Icons.restaurant
Transport Icons.directions_car
Shopping Icons.shopping_bag
Health Icons.local_hospital
Entertainment Icons.movie
Bills Icons.receipt
Other Icons.more_horiz

6.3 图标使用位置

分类图标被用在两个地方:

  • 横向分类汇总卡片。
  • 支出明细列表左侧 CircleAvatar

这能让用户通过视觉快速识别消费类型。

七、总支出计算

7.1 _totalExpenses getter

总支出通过 fold 计算。

double get _totalExpenses => _expenses.fold(0, (sum, e) => sum + e.amount);

这个 getter 每次读取时都会基于当前 _expenses 重新计算。

7.2 fold 计算过程

以默认数据为例:

0 + 4.50 = 4.50
4.50 + 2.00 = 6.50
6.50 + 12.00 = 18.50
18.50 + 15.00 = 33.50
33.50 + 30.00 = 63.50

7.3 顶部金额展示

Text(
  '\$${_totalExpenses.toStringAsFixed(2)}',
  style: const TextStyle(
    color: Colors.white,
    fontSize: 40,
    fontWeight: FontWeight.bold,
  ),
)

toStringAsFixed(2) 保证金额显示两位小数。

八、分类汇总计算

8.1 _categoryTotals getter

分类汇总使用 Map 聚合。

Map<String, double> get _categoryTotals {
  final totals = <String, double>{};
  for (final expense in _expenses) {
    totals[expense.category] = (totals[expense.category] ?? 0) + expense.amount;
  }
  return totals;
}

8.2 聚合逻辑

每一笔支出都会按分类累加:

totals[expense.category] =
    (totals[expense.category] ?? 0) + expense.amount;

如果分类还不存在,初始值按 0 处理。

8.3 分类聚合示例

默认数据中 Food 有 Coffee 和 Lunch 两笔:

Food = 4.50 + 12.00 = 16.50

Transport、Shopping、Health 各有一笔。

8.4 getter 的价值

_categoryTotals 不额外保存状态,而是从 _expenses 派生。新增或删除支出后,只要 setState 触发重建,分类汇总就会自动基于最新列表重新计算。

统计值优先做成派生状态。只保存原始支出列表,能减少总额和分类汇总不同步的问题。

九、顶部总金额卡片

9.1 渐变 Card

顶部卡片展示总支出。

Card(
  margin: const EdgeInsets.all(16),
  elevation: 8,
  shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
  child: Container(
    padding: const EdgeInsets.all(24),
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(16),
      gradient: LinearGradient(
        colors: [Colors.blue.shade400, Colors.blue.shade600],
        begin: Alignment.topLeft,
        end: Alignment.bottomRight,
      ),
    ),
  ),
)

蓝色渐变让总金额区域更突出。

9.2 标题和金额

const Text(
  'Total Expenses',
  style: TextStyle(color: Colors.white70, fontSize: 16),
)
Text(
  '\$${_totalExpenses.toStringAsFixed(2)}',
  style: const TextStyle(
    color: Colors.white,
    fontSize: 40,
    fontWeight: FontWeight.bold,
  ),
)

标题使用半透明白色,金额使用白色大字号。

9.3 金额变化

操作 影响
新增支出 总金额增加
删除支出 总金额减少
列表为空 总金额为 0.00

总金额是 getter,因此页面重建后会自动更新。

十、分类横向统计

10.1 显示条件

分类汇总不为空时显示横向统计区。

if (_categoryTotals.isNotEmpty)
  SizedBox(
    height: 80,
    child: ListView(
      scrollDirection: Axis.horizontal,
      padding: const EdgeInsets.symmetric(horizontal: 16),
      children: _categoryTotals.entries.map((entry) {
        return Container();
      }).toList(),
    ),
  )

10.2 分类卡片

每个分类卡片固定宽度 100。

Container(
  width: 100,
  margin: const EdgeInsets.only(right: 8),
  padding: const EdgeInsets.all(8),
  decoration: BoxDecoration(
    color: Colors.grey.shade100,
    borderRadius: BorderRadius.circular(12),
  ),
)

10.3 分类内容

Icon(_categoryIcons[entry.key], color: Colors.blue)
Text(
  '\$${entry.value.toStringAsFixed(0)}',
  style: const TextStyle(fontWeight: FontWeight.bold),
)
Text(
  entry.key,
  style: const TextStyle(fontSize: 10, color: Colors.grey),
  overflow: TextOverflow.ellipsis,
)

分类金额显示整数,分类名称超出时省略。

十一、新增支出弹窗

11.1 _addExpense 方法

新增支出通过弹窗完成。

void _addExpense() async {
  final titleController = TextEditingController();
  final amountController = TextEditingController();
  String selectedCategory = 'Other';

  await showDialog(
    context: context,
    builder: (context) => StatefulBuilder(
      builder: (context, setDialogState) => AlertDialog(
        title: const Text('Add Expense'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: [],
        ),
        actions: [],
      ),
    ),
  );
}

弹窗中使用两个输入控制器和一个局部分类变量。

11.2 标题输入

TextField(
  controller: titleController,
  decoration: const InputDecoration(
    labelText: 'Title',
    border: OutlineInputBorder(),
  ),
)

标题为空时不会添加记录。

11.3 金额输入

TextField(
  controller: amountController,
  keyboardType: TextInputType.number,
  decoration: const InputDecoration(
    labelText: 'Amount',
    prefixText: '\$ ',
    border: OutlineInputBorder(),
  ),
)

金额输入使用数字键盘,并在输入框前展示美元符号。

11.4 分类选择

DropdownButtonFormField<String>(
  value: selectedCategory,
  decoration: const InputDecoration(labelText: 'Category'),
  items: _categoryIcons.keys.map((cat) {
    return DropdownMenuItem(value: cat, child: Text(cat));
  }).toList(),
  onChanged: (value) =>
      setDialogState(() => selectedCategory = value ?? 'Other'),
)

分类选项来自 _categoryIcons.keys,保证图标映射和下拉选项一致。

十二、添加与删除逻辑

12.1 Add 按钮

点击 Add 时,代码先解析金额。

final amount = double.tryParse(amountController.text);
if (titleController.text.isNotEmpty && amount != null) {
  setState(() {
    _expenses.add(Expense(
      title: titleController.text,
      amount: amount,
      category: selectedCategory,
    ));
  });
  Navigator.pop(context);
}

标题非空且金额解析成功时才会新增记录。

12.2 Cancel 按钮

取消按钮只关闭弹窗。

TextButton(
  onPressed: () => Navigator.pop(context),
  child: const Text('Cancel'),
)

12.3 删除方法

删除方法按索引移除。

void _deleteExpense(int index) {
  setState(() {
    _expenses.removeAt(index);
  });
}

12.4 长按删除

列表项通过长按触发删除。

onLongPress: () => _deleteExpense(index),

当前源码没有删除确认,长按会直接删除对应支出。

十三、支出列表展示

13.1 空状态与列表状态

列表区域根据 _expenses.isEmpty 切换。

Expanded(
  child: _expenses.isEmpty
      ? const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.receipt_long, size: 64, color: Colors.grey),
              SizedBox(height: 16),
              Text('No expenses yet', style: TextStyle(color: Colors.grey)),
            ],
          ),
        )
      : ListView.builder(
          padding: const EdgeInsets.symmetric(horizontal: 16),
          itemCount: _expenses.length,
          itemBuilder: (context, index) {
            final expense = _expenses[index];
            return Card(child: ListTile());
          },
        ),
)

13.2 ListTile 结构

每条明细使用 ListTile

ListTile(
  leading: CircleAvatar(
    backgroundColor: Colors.blue.shade50,
    child: Icon(_categoryIcons[expense.category], color: Colors.blue),
  ),
  title: Text(expense.title),
  subtitle: Text(expense.category),
  trailing: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    crossAxisAlignment: CrossAxisAlignment.end,
    children: [
      Text('\$${expense.amount.toStringAsFixed(2)}'),
      Text('${expense.date.month}/${expense.date.day}'),
    ],
  ),
  onLongPress: () => _deleteExpense(index),
)

13.3 明细信息表

区域 内容
leading 分类图标
title 支出标题
subtitle 支出分类
trailing 第一行 金额
trailing 第二行 月/日
长按 删除记录

13.4 日期展示

日期以月/日形式展示。

'${expense.date.month}/${expense.date.day}'

例如 6 月 9 日会显示为 6/9

十四、OpenHarmony 适配要点

14.1 基础组件验证

当前项目使用的 Flutter 组件包括:

组件 作用 OpenHarmony 关注点
MaterialApp 应用根组件 首屏加载
Scaffold 页面骨架 AppBar、Body、FAB
Card 总额和列表卡片 圆角、阴影、渐变
ListView 分类横向列表 横向滚动
ListView.builder 支出明细 动态列表
AlertDialog 新增弹窗 弹窗尺寸和输入
TextField 标题和金额输入 键盘、焦点
DropdownButtonFormField 分类选择 菜单展开
FloatingActionButton 新增入口 悬浮按钮点击
ListTile 明细行 长按手势

14.2 表单验证

OpenHarmony 上应重点验证:

  1. 点击 FAB 能打开新增弹窗。
  2. 标题输入框能正常输入。
  3. 金额输入框能弹出数字键盘。
  4. 分类下拉能展开并选择。
  5. 金额为合法数字时可以新增。
  6. 标题为空时不会新增。
  7. 金额解析失败时不会新增。

14.3 列表与统计验证

新增和删除会同时影响三个区域:

  • 顶部总金额。
  • 分类横向汇总。
  • 底部支出明细列表。

每次新增或删除后,都应确认三个区域是否同步刷新。

14.4 空状态验证

长按删除所有记录后,页面应显示:

No expenses yet

同时顶部总金额应变为 $0.00,分类横向汇总区域应隐藏。

OpenHarmony 适配不能只看新增弹窗是否打开。记账类页面要同时验证输入、统计、列表、删除和空状态。

十五、测试与验证

15.1 初始页面测试

Widget 测试可以验证默认数据。

import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('expense tracker shows default expenses', (tester) async {
    await tester.pumpWidget(const ExpenseTrackerApp());

    expect(find.text('Expense Tracker'), findsWidgets);
    expect(find.text('Total Expenses'), findsOneWidget);
    expect(find.text('Coffee'), findsOneWidget);
    expect(find.text('Bus Ticket'), findsOneWidget);
    expect(find.text('Lunch'), findsOneWidget);
  });
}

15.2 总金额测试思路

可以把总金额计算抽成纯函数。

double calculateTotal(List<Expense> expenses) {
  return expenses.fold(0, (sum, expense) => sum + expense.amount);
}

测试示例:

void main() {
  test('calculate total expenses', () {
    final expenses = [
      Expense(title: 'A', amount: 1.5, category: 'Food'),
      Expense(title: 'B', amount: 2.5, category: 'Other'),
    ];

    expect(calculateTotal(expenses), 4.0);
  });
}

15.3 分类聚合测试思路

分类聚合也可以抽成纯函数。

Map<String, double> calculateCategoryTotals(List<Expense> expenses) {
  final totals = <String, double>{};
  for (final expense in expenses) {
    totals[expense.category] = (totals[expense.category] ?? 0) + expense.amount;
  }
  return totals;
}

测试示例:

void main() {
  test('calculate category totals', () {
    final totals = calculateCategoryTotals([
      Expense(title: 'Coffee', amount: 4.5, category: 'Food'),
      Expense(title: 'Lunch', amount: 12, category: 'Food'),
    ]);

    expect(totals['Food'], 16.5);
  });
}

15.4 手工验证矩阵

场景 操作 预期
首次打开 启动应用 显示 5 条默认支出
查看总额 查看顶部卡片 显示 $63.50
新增支出 输入标题、金额、分类 列表新增记录
分类汇总 新增 Food 支出 Food 分类金额增加
删除记录 长按某条支出 记录移除,总额减少
删除全部 长按删除所有支出 显示空状态
非法金额 输入非数字金额 不新增记录

十六、常见问题与优化建议

16.1 为什么总金额用 getter

总金额是 _expenses 的派生值。

double get _totalExpenses => _expenses.fold(0, (sum, e) => sum + e.amount);

使用 getter 可以避免新增或删除后忘记同步总金额。

16.2 为什么分类汇总用 Map

分类聚合天然适合用 Map<String, double>

final totals = <String, double>{};

分类名作为 key,分类金额作为 value,累加逻辑清晰。

16.3 为什么弹窗分类使用 StatefulBuilder

弹窗中的 selectedCategory 是局部状态。

StatefulBuilder(
  builder: (context, setDialogState) => AlertDialog(),
)

使用 StatefulBuilder 可以只刷新弹窗内部下拉选择,不必把分类选择提升到页面全局状态。

16.4 金额为什么需要更严格校验

当前代码只判断 double.tryParse 是否成功。

final amount = double.tryParse(amountController.text);

这意味着 0 或负数也可能通过解析。正式记账场景通常还需要校验金额大于 0。

if (amount != null && amount > 0) {
  // 添加支出
}

16.5 为什么长按删除不够直观

长按手势不一定容易被用户发现。当前代码没有删除确认,误触长按也会直接删除。

onLongPress: () => _deleteExpense(index)

更稳妥的交互是显示删除图标、滑动删除,或者增加确认弹窗。

16.6 如何增加持久化

当前数据保存在内存中,应用重启后会恢复默认数据。可以把支出记录序列化到本地存储。

class ExpenseDto {
  final String title;
  final double amount;
  final String category;
  final String date;

  const ExpenseDto({
    required this.title,
    required this.amount,
    required this.category,
    required this.date,
  });
}

OpenHarmony 上实现持久化时,需要结合可用插件和平台存储能力。

十七、工程扩展方向

17.1 抽取 ExpenseTile

明细列表项可以拆成独立组件。

class ExpenseTile extends StatelessWidget {
  final Expense expense;
  final IconData? icon;
  final VoidCallback onDelete;

  const ExpenseTile({
    super.key,
    required this.expense,
    required this.icon,
    required this.onDelete,
  });
}

拆分后,页面主体会更清晰。

17.2 抽取新增表单结果

新增弹窗可以返回一个结果对象。

class ExpenseFormResult {
  final String title;
  final double amount;
  final String category;

  const ExpenseFormResult({
    required this.title,
    required this.amount,
    required this.category,
  });
}

页面只负责接收结果并添加到 _expenses

17.3 增加日期选择

当前新增支出默认使用当前日期。可以加入日期选择器。

class ExpenseInput {
  final String title;
  final double amount;
  final String category;
  final DateTime date;

  const ExpenseInput({
    required this.title,
    required this.amount,
    required this.category,
    required this.date,
  });
}

这样可以补录历史账单。

17.4 增加月度统计

可以按月份汇总支出。

String monthKey(DateTime date) {
  return '${date.year}-${date.month.toString().padLeft(2, '0')}';
}

月度统计适合扩展预算、趋势图和分类分析。

总结

expense_tracker 是一个结构清晰的 Flutter 记账类项目。它用 Expense 模型保存标题、金额、分类和日期,用 _expenses 保存当前支出列表,用 _totalExpenses 通过 fold 派生总金额,用 _categoryTotals 通过 Map 聚合分类支出,并用顶部总额卡片、横向分类统计和底部明细列表形成完整展示链路。

从 OpenHarmony 适配角度看,这个项目适合验证 Flutter 表单输入、数字键盘、下拉选择、弹窗局部状态、横向列表、动态明细列表、长按手势、空状态和渐变卡片。排查路径也很明确:总额不对看 _totalExpenses,分类不对看 _categoryTotals,新增失败看输入解析和表单校验,删除异常看列表索引和 setState

掌握这个项目后,可以继续扩展删除确认、金额正数校验、日期选择、本地持久化、月度统计、预算管理和图表分析,让消费记录器从内存 Demo 演进为更完整的跨平台记账工具。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐