Skip to content

封装与复用

在 Flutter 开发中,封装就是把重复出现的逻辑或 UI 片段提取出来,变成可复用的独立单元。封装的好处很直接:少写重复代码、改一处全局生效、降低出错率

封装的四个层次

层次封装对象典型手段复用范围
1通用方法/计算工具类(static 方法)全项目
2共享行为Mixin跨类复用
3已有类型的增强Extension增强已有类
4可复用 UI 片段自定义 Widget多页面复用

一、工具类封装(通用方法)

当你发现同一个计算逻辑出现在多个地方时,就该提取成工具方法。

dart
// lib/utils/date_formatter.dart
class DateFormatter {
  /// 将 DateTime 格式化为 yyyy-MM-dd
  static String formatDate(DateTime date) {
    return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
  }

  /// 友好时间:刚刚 / x分钟前 / x小时前
  static String timeAgo(DateTime date) {
    final diff = DateTime.now().difference(date);
    if (diff.inMinutes < 1) return '刚刚';
    if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
    if (diff.inDays < 1) return '${diff.inHours}小时前';
    return formatDate(date);
  }
}
dart
// lib/utils/validators.dart
class Validators {
  static String? required(String? value, [String label = '此项']) {
    if (value == null || value.trim().isEmpty) return '$label不能为空';
    return null;
  }

  static String? email(String? value) {
    if (value == null || value.isEmpty) return '请输入邮箱';
    final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!regex.hasMatch(value)) return '邮箱格式不正确';
    return null;
  }
}

工具类要点

  • 方法用 static 修饰,不需要创建实例就能调用
  • 类名要语义清晰,如 DateFormatterValidators
  • 放在 lib/utils/ 目录下,按职责拆分文件

二、Mixin 封装(共享行为)

当多个类需要相同的字段或方法,但又不在同一条继承链上时,用 Mixin。

dart
// lib/mixins/loading_mixin.dart
mixin LoadingMixin on State {
  bool _isLoading = false;
  bool get isLoading => _isLoading;

  void showLoading() => setState(() => _isLoading = true);
  void hideLoading() => setState(() => _isLoading = false);

  /// 便捷方法:执行异步操作时自动管理 loading 状态
  Future<T> withLoading<T>(Future<T> Function() action) async {
    showLoading();
    try {
      return await action();
    } finally {
      hideLoading();
    }
  }
}

在多个页面中复用:

dart
class _HomePageState extends State<HomePage> with LoadingMixin {
  Future<void> loadData() async {
    await withLoading(() async {
      final data = await api.fetchData();
      // 处理数据...
    });
  }

  @override
  Widget build(BuildContext context) {
    if (isLoading) return const CircularProgressIndicator();
    return ListView(...);
  }
}

Mixin 要点

  • mixin 关键字声明,而不是 class
  • on State 限定只能用于 State 子类,这样才能调用 setState
  • 一个类可以 with 多个 Mixin,比继承更灵活

三、Extension 封装(增强已有类型)

当你想给别人写的类(如 StringBuildContextDateTime)添加方法,又不能修改源码时,用 Extension。

dart
// lib/extensions/string_extension.dart
extension StringX on String {
  bool get isPhone => RegExp(r'^1[3-9]\d{9}$').hasMatch(this);
  bool get isEmail => RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
  String get capitalize => isEmpty ? '' : '${this[0].toUpperCase()}${substring(1)}';
  String ellipsis(int maxLen) => length > maxLen ? '${substring(0, maxLen)}...' : this;
}
dart
// lib/extensions/context_extension.dart
extension ContextX on BuildContext {
  ThemeData get theme => Theme.of(this);
  Size get screenSize => MediaQuery.sizeOf(this);
  double get bottomPadding => MediaQuery.viewPaddingOf(this).bottom;

  void showSnackBar(String message) {
    ScaffoldMessenger.of(this).showSnackBar(SnackBar(content: Text(message)));
  }
}

Extension 要点

  • 不修改原类代码,无侵入地增强已有类型
  • 放在 lib/extensions/ 目录下
  • 注意:Extension 方法不能访问私有成员

四、Widget 封装(可复用 UI 组件)

当一段 UI 在多个页面重复出现时,提取为自定义 Widget。

dart
// lib/widgets/state_view.dart
enum ViewState { loading, empty, error, content }

class StateView extends StatelessWidget {
  const StateView({
    super.key,
    required this.state,
    required this.builder,
    this.emptyText = '暂无数据',
    this.errorText = '加载失败',
    this.onRetry,
  });

  final ViewState state;
  final WidgetBuilder builder;
  final String emptyText;
  final String errorText;
  final VoidCallback? onRetry;

  @override
  Widget build(BuildContext context) {
    return switch (state) {
      ViewState.loading => const Center(child: CircularProgressIndicator()),
      ViewState.empty => Center(child: Text(emptyText)),
      ViewState.error => Center(child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(errorText),
          if (onRetry != null) ElevatedButton(onPressed: onRetry, child: Text('重试')),
        ],
      )),
      ViewState.content => builder(context),
    };
  }
}
dart
// lib/widgets/primary_button.dart
class PrimaryButton extends StatelessWidget {
  const PrimaryButton({
    super.key,
    required this.onPressed,
    required this.text,
    this.isLoading = false,
  });

  final VoidCallback? onPressed;
  final String text;
  final bool isLoading;

  @override
  Widget build(BuildContext context) {
    return FilledButton(
      onPressed: isLoading ? null : onPressed,
      child: isLoading
          ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
          : Text(text),
    );
  }
}

Widget 封装要点

  • 至少 2-3 处复用时才提取,不要过度封装
  • 通过构造函数参数控制变化的部分(如文案、颜色、回调)
  • 不变的部分在 Widget 内部固定(如间距、字号、图标)
  • 放在 lib/widgets/ 目录下

五、服务类封装(业务逻辑)

当某个功能涉及多步操作维护内部状态时,用服务类封装。

dart
// lib/services/storage_service.dart
class StorageService {
  static StorageService? _instance;
  late SharedPreferences _prefs;

  static Future<StorageService> get instance async {
    _instance ??= StorageService._();
    await _instance!._init();
    return _instance!;
  }

  StorageService._();

  Future<void> _init() async {
    _prefs = await SharedPreferences.getInstance();
  }

  String? getToken() => _prefs.getString('token');
  Future<void> setToken(String token) => _prefs.setString('token', token);
  bool get isLoggedIn => getToken() != null;
}

六、包的导出与引用

封装好了代码,还得让其他文件能方便地使用。这就涉及 Dart 的 import(导入)和 export(导出)机制。

import — 导入别人的代码

Dart 有两种导入方式:

dart
// 1. package: 导入(最常用)—— 从当前项目的 lib/ 或第三方包导入
import 'package:my_app/utils/validators.dart';    // 自己项目的文件
import 'package:dio/dio.dart';                     // 第三方包

// 2. 相对路径导入 —— 从当前文件的相对位置导入
import '../utils/validators.dart';                 // 上级目录的文件
import 'src/internal_helper.dart';                  // 子目录的文件

使用规则:

场景推荐方式原因
导入 lib/ 下的文件package:不受文件移动影响,路径稳定
导入 lib/ 外的文件(如 test/相对路径package: 只能访问 lib/
导入第三方包package:唯一方式

给导入起别名(解决命名冲突):

dart
import 'package:http/http.dart' as http;  // 别名 http

// 使用时
http.get(Uri.parse('...'));

选择性导入(只导入部分内容):

dart
// 只导入 show 后面列出的内容
import 'package:flutter/material.dart' show Colors, Icons;

// 隐藏 hide 后面列出的内容(避免冲突)
import 'package:flutter/material.dart' hide Colors;

export — 导出自己的公共 API

export 的作用是把多个文件的公开内容统一汇总到一个入口文件,让使用者只需导入一个文件就够了。

问题:没有 export 时

dart
// 使用者需要记住每个文件的路径,逐一导入
import 'package:my_app/utils/date_formatter.dart';
import 'package:my_app/utils/validators.dart';
import 'package:my_app/utils/number_formatter.dart';

解决:用 export 做 barrel 文件

创建一个汇总文件(通常叫 xxx.dartxxx/index.dart),把所有公共 API 集中导出:

dart
// lib/utils/utils.dart(barrel 文件)
export 'date_formatter.dart';
export 'validators.dart';
export 'number_formatter.dart';

使用者只需一行导入:

dart
import 'package:my_app/utils/utils.dart';  // 一个入口搞定

// 现在可以直接用 DateFormatter、Validators、NumberFormatter

实际项目中的 barrel 文件组织

lib/
├── utils/
│   ├── utils.dart              ← barrel 文件(汇总导出)
│   ├── date_formatter.dart     ← 具体实现
│   ├── validators.dart         ← 具体实现
│   └── number_formatter.dart   ← 具体实现
├── widgets/
│   ├── widgets.dart            ← barrel 文件
│   ├── state_view.dart
│   ├── primary_button.dart
│   └── loading_indicator.dart
└── extensions/
    ├── extensions.dart          ← barrel 文件
    ├── string_extension.dart
    └── context_extension.dart

使用时:

dart
import 'package:my_app/utils/utils.dart';
import 'package:my_app/widgets/widgets.dart';
import 'package:my_app/extensions/extensions.dart';

barrel 文件命名约定

  • 文件夹名 + .dartutils/utils.dartwidgets/widgets.dart
  • index.dartutils/index.dart(导入时写 package:my_app/utils/

选择性导出

dart
// 只导出部分内容
export 'date_formatter.dart' show DateFormatter;
export 'validators.dart' show Validators, email;

// 隐藏内部实现
export 'storage_service.dart' hide StorageServiceImpl;

创建自己的包

当你的封装代码需要在多个项目中复用时,可以把它提取为独立的 Dart 包。

创建包项目

bash
# 创建一个 Dart 包(纯 Dart 代码,无平台依赖)
dart create -t package my_package

# 创建一个 Flutter 插件(包含平台原生代码)
flutter create --template=plugin --platforms=android,ios,ohos my_plugin

包项目结构

my_package/
├── lib/                     # ⭐ 公共代码(别人 import 的就是这里)
│   ├── my_package.dart      # 主入口(barrel 文件)
│   ├── src/                 # 内部实现
│   │   ├── calculator.dart
│   │   └── formatter.dart
│   └── ...
├── test/                    # 测试
├── pubspec.yaml             # 包配置
└── README.md

关键:lib/ 目录是公共 API,lib/src/ 是内部实现。

dart
// lib/my_package.dart(主入口,导出公共 API)
export 'src/calculator.dart';
export 'src/formatter.dart';

pubspec.yaml 配置

yaml
name: my_package              # 包名(import 时用这个名字)
description: 我的工具包
version: 1.0.0

environment:
  sdk: ^3.6.0

dependencies:
  # 这个包依赖的其他包
  http: ^1.2.0

dev_dependencies:
  # 只在开发和测试时用的包
  test: ^1.25.0

在项目中引用本地包

开发阶段,包还没发布到 pub.dev,可以通过路径引用本地包:

方式一:路径引用(推荐开发时使用)

my_workspace/
├── my_app/                  # 你的应用项目
│   ├── pubspec.yaml
│   └── lib/
└── my_package/              # 你的包项目
    ├── pubspec.yaml
    └── lib/

my_app/pubspec.yaml 中:

yaml
dependencies:
  my_package:
    path: ../my_package     # 相对路径指向本地包

使用时与普通包完全一致:

dart
import 'package:my_package/my_package.dart';

方式二:Git 引用

yaml
dependencies:
  my_package:
    git:
      url: https://github.com/username/my_package.git
      ref: v1.0.0           # 分支名、tag 或 commit hash

方式三:pub.dev 引用

yaml
dependencies:
  my_package: ^1.0.0        # 从 pub.dev 下载

发布包到 pub.dev

当包开发完成并测试通过后,可以发布到 pub.dev 供全球开发者使用。

发布前检查

bash
# 1. 检查包是否有问题
dart pub publish --dry-run

# 2. 确认以下内容
# - pubspec.yaml 中填写了 description、homepage/repository
# - 有 LICENSE 文件
# - 有 README.md
# - 版本号符合语义化版本(SemVer):主版本.次版本.修订号

发布

bash
# 正式发布
dart pub publish

注意

  • pub.dev 上的包名一旦发布就永久占用,不能删除
  • 每次发布版本号必须递增
  • 发布后无法撤回,只能发布新版本修复

版本号规则(语义化版本)

主版本.次版本.修订号

1.0.0   首次正式发布
1.0.1   修复 bug(向后兼容)
1.1.0   新增功能(向后兼容)
2.0.0   破坏性变更(不向后兼容)

pubspec.yaml 中声明版本:

yaml
version: 1.0.0

导入导出速查

需求做法
导入自己项目的文件import 'package:my_app/xxx.dart'
导入第三方包import 'package:dio/dio.dart'
汇总导出多个文件创建 barrel 文件用 export
隐藏内部实现代码放 lib/src/,只 export 公共部分
引用本地未发布的包pubspec.yaml 中用 path: 引用
发布包到 pub.devdart pub publish

封装决策速查

这段代码要复用吗?
├── 否 → 不需要封装,保持原样
└── 是 → 复用的是什么?
    ├── 纯计算/校验逻辑 → 工具类(static 方法)
    ├── 给已有类加方法 → Extension
    ├── 跨类的共享行为 → Mixin
    ├── 可复用 UI 片段 → 自定义 Widget
    ├── 有状态的业务逻辑 → 服务类
    └── 跨项目复用 → 独立包(export + pubspec.yaml)

避免过度封装

  • 只有一个地方使用的代码不需要封装
  • 不要为了封装而封装,2-3 处复用是提取的最低门槛
  • 封装后的命名要让人一看就知道做什么

下一步

基于 Flutter 官方文档整理