Skip to content

封装与复用

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

封装的四个层次

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

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

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

问题:重复代码散落各处

dart
// ❌ 日期格式化逻辑散落在多个页面
Text('创建于 ${order.createdAt.year}-${order.createdAt.month.toString().padLeft(2, '0')}-${order.createdAt.day.toString().padLeft(2, '0')}');

Text('截止 ${task.deadline.year}-${task.deadline.month.toString().padLeft(2, '0')}-${task.deadline.day.toString().padLeft(2, '0')}');

解决:提取到工具类

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

  /// 将 DateTime 格式化为 yyyy-MM-dd HH:mm
  static String formatDateTime(DateTime date) {
    final dateStr = formatDate(date);
    final hour = date.hour.toString().padLeft(2, '0');
    final minute = date.minute.toString().padLeft(2, '0');
    return '$dateStr $hour:$minute';
  }

  /// 友好时间:刚刚 / 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
// 只需 import 后调用
import 'package:my_app/utils/date_formatter.dart';

Text('创建于 ${DateFormatter.formatDate(order.createdAt)}');
Text('截止 ${DateFormatter.timeAgo(task.deadline)}');

另一个常见示例:输入校验

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 String? phone(String? value) {
    if (value == null || value.isEmpty) return '请输入手机号';
    final regex = RegExp(r'^1[3-9]\d{9}$');
    if (!regex.hasMatch(value)) return '手机号格式不正确';
    return null;
  }
}
dart
// 在表单中使用
import 'package:my_app/utils/validators.dart';

TextFormField(
  validator: (v) => Validators.email(v),    // 直接传入
),
TextFormField(
  validator: (v) => Validators.required(v, '用户名'),  // 带自定义提示
),

工具类要点

  • 方法用 static 修饰,不需要创建实例就能调用
  • 类名要语义清晰,如 DateFormatterValidators
  • 放在 lib/utils/ 目录下,按职责拆分文件
  • 每个方法做一件事,参数和返回值类型明确

二、Mixin 封装(共享行为)

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

问题:多个页面都需要加载状态

dart
// ❌ 每个页面都重复定义 isLoading 和相关方法
class HomePage extends StatefulWidget { ... }
class _HomePageState extends State<HomePage> {
  bool _isLoading = false;
  void setLoading(bool v) => setState(() => _isLoading = v);
  // ... 业务逻辑
}

class ProfilePage extends StatefulWidget { ... }
class _ProfileState extends State<ProfilePage> {
  bool _isLoading = false;           // 重复!
  void setLoading(bool v) => setState(() => _isLoading = v);  // 重复!
  // ... 业务逻辑
}

解决:提取为 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
import 'package:my_app/mixins/loading_mixin.dart';

class HomePage extends StatefulWidget { ... }

class _HomePageState extends State<HomePage> with LoadingMixin {
  // 直接使用 isLoading、showLoading()、hideLoading()、withLoading()

  Future<void> loadData() async {
    await withLoading(() async {
      final data = await api.fetchData();
      // 处理数据...
    });
  }

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

class ProfilePage extends StatefulWidget { ... }

class _ProfileState extends State<ProfilePage> with LoadingMixin {
  // 同样拥有 loading 能力,零重复代码
  Future<void> refreshProfile() async {
    await withLoading(() => api.fetchProfile());
  }
}

Mixin 要点

  • mixin 关键字声明,而不是 class
  • on State 限定只能用于 State 子类,这样才能调用 setState
  • 一个类可以 with 多个 Mixin,比继承更灵活
  • 适合封装横切关注点:加载状态、日志、动画控制等

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

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

示例:给 String 添加常用判断

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

  /// 保留前 n 位 + 省略号
  String ellipsis(int maxLen) =>
      length > maxLen ? '${substring(0, maxLen)}...' : this;
}

在其他文件中使用

dart
import 'package:my_app/extensions/string_extension.dart';

// 像调用原生方法一样自然
if (input.isPhone) { /* 提交 */ }
if (input.isEmail) { /* 发送验证码 */ }
Text(name.capitalize);
Text(longTitle.ellipsis(20));

示例:给 BuildContext 添加便捷方法

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;

  /// 快速弹出 SnackBar
  void showSnackBar(String message) {
    ScaffoldMessenger.of(this).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }
}
dart
import 'package:my_app/extensions/context_extension.dart';

// 使用
final width = context.screenSize.width;
context.showSnackBar('保存成功');

Extension 要点

  • 不修改原类代码,无侵入地增强已有类型
  • 放在 lib/extensions/ 目录下
  • 命名约定:XExtension(如 StringX)或 XExt
  • 注意:Extension 方法不能访问私有成员

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

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

问题:多个页面都有相同的加载/空数据/错误状态

dart
// ❌ 每个页面都写一堆状态判断
if (isLoading) {
  return const Center(child: CircularProgressIndicator());
} else if (hasError) {
  return Center(child: Text('加载失败'));
} else if (data.isEmpty) {
  return Center(child: Text('暂无数据'));
} else {
  return ListView(...);
}

解决:封装通用状态视图

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) {
    switch (state) {
      case ViewState.loading:
        return const Center(child: CircularProgressIndicator());
      case ViewState.empty:
        return Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[400]),
              const SizedBox(height: 8),
              Text(emptyText, style: TextStyle(color: Colors.grey[600])),
            ],
          ),
        );
      case ViewState.error:
        return Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(Icons.error_outline, size: 64, color: Colors.grey[400]),
              const SizedBox(height: 8),
              Text(errorText, style: TextStyle(color: Colors.grey[600])),
              if (onRetry != null) ...[
                const SizedBox(height: 16),
                ElevatedButton(onPressed: onRetry, child: const Text('重试')),
              ],
            ],
          ),
        );
      case ViewState.content:
        return builder(context);
    }
  }
}

在页面中使用

dart
import 'package:my_app/widgets/state_view.dart';

class HomePage extends StatefulWidget { ... }

class _HomePageState extends State<HomePage> {
  ViewState _state = ViewState.loading;
  List<String> _data = [];

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  Future<void> _loadData() async {
    setState(() => _state = ViewState.loading);
    try {
      _data = await api.fetchData();
      setState(() => _state = _data.isEmpty ? ViewState.empty : ViewState.content);
    } catch (e) {
      setState(() => _state = ViewState.error);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: StateView(
        state: _state,
        onRetry: _loadData,
        builder: (context) => ListView.builder(
          itemCount: _data.length,
          itemBuilder: (_, i) => ListTile(title: Text(_data[i])),
        ),
      ),
    );
  }
}

另一个示例:封装常用按钮样式

dart
// lib/widgets/custom_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),
    );
  }
}
dart
// 使用
PrimaryButton(
  text: '提交',
  isLoading: _isSubmitting,
  onPressed: _handleSubmit,
)

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);
  Future<void> removeToken() => _prefs.remove('token');

  bool get isLoggedIn => getToken() != null;

  Future<void> logout() async {
    await removeToken();
    // 清理其他缓存...
  }
}
dart
// 使用
final storage = await StorageService.instance;
if (storage.isLoggedIn) { /* 跳转首页 */ }
await storage.setToken('xxx');

服务类要点

  • 适合封装网络请求、本地存储、认证等有状态的逻辑
  • 一般用单例模式(static instance),确保全局唯一
  • 放在 lib/services/ 目录下

封装决策速查

遇到重复代码时,按以下流程选择封装方式:

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

避免过度封装

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

下一步

基于 Flutter 官方文档整理