封装与复用
在 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修饰,不需要创建实例就能调用 - 类名要语义清晰,如
DateFormatter、Validators - 放在
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 封装(增强已有类型)
当你想给别人写的类(如 String、BuildContext、DateTime)添加方法,又不能修改源码时,用 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 处复用是提取的最低门槛
- 封装后的命名要让人一看就知道做什么,而不是需要点进去看代码
