flutter_for_openharmony城市井盖地图app实战+创建工单实现

1. 这个功能解决什么问题
现场巡检时经常需要“手动创建工单”,该功能核心要满足以下业务诉求:
- 标题输入:工单主题,便于快速识别工单核心诉求,是后续筛选、检索工单的关键维度
- 井盖编号:精准关联具体的井盖点位,确保工单能定位到物理设施,避免维修/巡检找错位置
- 片区下拉:快速归属到行政区,方便按区域分配运维人员、统计区域工单量
- 表单校验:关键字段不能为空,避免提交无效工单,减少后端数据清洗成本
- 提交反馈:模拟保存成功提示,让用户明确知道操作结果,提升交互体验
这个页面是典型的“表单录入”场景,适合做 Form + TextEditingController 的最佳实践示例,既覆盖基础表单能力,也能体现Flutter在状态管理、资源释放上的规范用法。
2. 相关文件一览
lib/feature_pages.dart(CreateWorkOrderPage):核心页面文件,包含工单创建的所有UI渲染、表单逻辑、交互处理
3. 表单字段定义
在Flutter中,表单的核心是通过GlobalKey绑定Form组件,实现统一的校验触发;TextEditingController则负责管理输入框的文本状态,包括初始值、文本变更、资源释放等。
class _CreateWorkOrderPageState extends State<CreateWorkOrderPage> {
final _formKey = GlobalKey<FormState>();
final _title = TextEditingController(text: '井盖巡检');
final _coverCode = TextEditingController(text: 'MH-1000');
String _district = '东城区';
}
关于这段核心代码的设计要点:
_formKey:全局唯一标识Form组件,后续通过_formKey.currentState?.validate()触发全表单校验,是Flutter表单校验的标准方式TextEditingController初始化:给标题和井盖编号设置默认值,减少用户录入工作量,符合“提效型表单”的设计思路_district默认值:选择高频使用的“东城区”作为初始片区,贴合现场巡检的区域分布特点- 变量命名:采用下划线开头的私有变量,符合Dart的封装规范,避免外部误操作状态
4. 标题输入框
标题是工单的核心标识字段,必须做非空校验,且要过滤纯空格的无效输入。
TextFormField(
controller: _title,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '标题',
),
validator: (v) => (v ?? '').trim().isEmpty ? '请输入标题' : null,
)
该输入框的设计细节拆解:
- 绑定控制器:
controller: _title将输入框与文本控制器关联,实现文本的双向绑定 - 样式装饰:
OutlineInputBorder是Material Design风格的标准边框,labelText提示用户输入内容,提升易用性 - 校验逻辑:
- 先通过
v ?? ''处理null值,避免空指针异常 - 再通过
trim()去除首尾空格,防止用户输入纯空格绕过校验 - 校验失败返回提示文本,成功返回null,符合Flutter validator的回调规范
- 先通过
5. 井盖编号输入框
井盖编号是关联物理设施的核心字段,校验规则与标题一致,但默认值设计更贴合业务(编号格式为MH+四位数字)。
TextFormField(
controller: _coverCode,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '关联井盖编号',
),
validator: (v) => (v ?? '').trim().isEmpty ? '请输入井盖编号' : null,
)
该字段的业务设计考量:
- 默认值
MH-1000:遵循项目中井盖编号的统一命名规范,用户可直接修改后四位数字,提升录入效率 - 输入框标识:
labelText明确标注“关联井盖编号”,避免用户混淆为“工单编号” - 校验复用:与标题使用相同的校验逻辑,保证表单校验规则的一致性,降低维护成本
6. 片区下拉
片区选择采用DropdownButtonFormField组件,既保留Form字段的校验能力,又实现下拉选择的交互,是表单中选择类字段的最优解。
DropdownButtonFormField<String>(
value: _district,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '片区',
),
items: const [
DropdownMenuItem(value: '东城区', child: Text('东城区')),
],
)
先拆解核心初始化部分的设计:
value: _district:绑定当前选中的片区值,实现“值与视图”的联动- 样式统一:与输入框使用相同的
OutlineInputBorder边框,保证表单视觉风格一致 - 下拉项定义:
DropdownMenuItem是下拉组件的基础单元,value为实际存储值,child为展示文本
补充完整的下拉项和变更逻辑:
items: const [
DropdownMenuItem(value: '西城区', child: Text('西城区')),
DropdownMenuItem(value: '南城区', child: Text('南城区')),
DropdownMenuItem(value: '北城区', child: Text('北城区')),
DropdownMenuItem(value: '高新区', child: Text('高新区')),
],
onChanged: (v) => setState(() => _district = v ?? '东城区'),
)
这段代码的关键设计点:
- 硬编码片区:与Mock数据保持一致,适合小型项目的快速落地,后续可扩展为接口拉取
onChanged回调:- 通过
setState更新状态,触发UI重绘,展示新选中的片区 - 使用
v ?? '东城区'兜底,防止用户操作时出现null值,保证状态的安全性
- 通过
- 片区覆盖:包含项目中所有运维区域,满足不同片区的工单创建需求
7. 提交按钮
提交按钮独立于Form组件之外,通过手动调用Form的校验方法,实现“点击提交→校验→反馈”的完整流程。
FilledButton.icon(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已创建工单: ${_title.text} (${_district}) (模拟)')),
);
},
icon: const Icon(Icons.add_task),
label: const Text('创建'),
)
提交逻辑的分步解析:
- 按钮样式:
FilledButton.icon结合图标和文本,视觉上更醒目,符合“创建工单”的操作意图 - 校验触发:
_formKey.currentState?.validate()遍历所有Form字段的validator,全部通过返回true- 使用
?? false处理currentState为null的极端情况,避免逻辑异常 - 校验失败直接return,终止后续操作
- 反馈提示:
- 通过
ScaffoldMessenger展示SnackBar,是Flutter中轻量级反馈的标准方式 - 提示文本包含标题和片区,让用户明确知道创建的工单信息
- 标注“模拟”,区分测试环境和生产环境的操作结果
- 通过
8. 资源释放
Flutter中TextEditingController 持有资源,页面销毁时必须手动释放,否则会导致内存泄漏,尤其在表单页面频繁进出的场景下。
void dispose() {
_title.dispose();
_coverCode.dispose();
super.dispose();
}
资源释放的核心要点:
- 生命周期时机:
dispose是StatefulWidget的销毁回调,仅在页面销毁时执行 - 释放顺序:先释放自定义控制器,再调用
super.dispose(),符合Dart的生命周期规范 - 覆盖范围:所有
TextEditingController都要释放,避免遗漏导致的内存问题 - 必要性:对于高频访问的表单页面,内存泄漏会逐渐累积,影响应用性能和稳定性
9. 完整页面代码
第一步:页面结构定义
class CreateWorkOrderPage extends StatefulWidget {
const CreateWorkOrderPage({super.key});
State<CreateWorkOrderPage> createState() => _CreateWorkOrderPageState();
}
这是StatefulWidget的标准定义:
- 不可变构造函数:使用
const修饰,提升性能,适合无外部参数的页面 createState:创建对应的State类,承载页面的状态和逻辑- 命名规范:页面类名采用大驼峰,符合Flutter的代码规范
第二步:状态类初始化
class _CreateWorkOrderPageState extends State<CreateWorkOrderPage> {
final _formKey = GlobalKey<FormState>();
final _title = TextEditingController(text: '井盖巡检');
final _coverCode = TextEditingController(text: 'MH-1000');
String _district = '东城区';
}
状态类的核心职责:
- 存储表单相关状态:包括表单key、输入控制器、片区选择值
- 私有状态:通过下划线私有化,仅在当前State类中访问,保证状态安全
第三步:资源释放方法
void dispose() {
_title.dispose();
_coverCode.dispose();
super.dispose();
}
如前文所述,这是防止内存泄漏的关键步骤,必须实现。
第四步:页面构建方法(基础结构)
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('创建工单')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
// 表单字段将在这里填充
],
),
),
);
}
页面基础结构的设计要点:
Scaffold:提供页面的基础骨架,包含AppBar和BodyAppBar:明确页面标题,符合用户的操作预期Form绑定key:将表单与全局key关联,为后续校验做准备ListView:替代Column+SingleChildScrollView,适配不同屏幕高度,避免键盘弹出时溢出
第五步:填充表单字段(标题)
TextFormField(
controller: _title,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '标题',
),
validator: (v) => (v ?? '').trim().isEmpty ? '请输入标题' : null,
),
const SizedBox(height: 12),
补充设计细节:
SizedBox(height: 12):字段之间增加间距,提升表单的可读性,符合Material Design的间距规范- 输入框样式:统一的边框和标签,保证视觉一致性
第六步:填充井盖编号字段
TextFormField(
controller: _coverCode,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '关联井盖编号',
),
validator: (v) => (v ?? '').trim().isEmpty ? '请输入井盖编号' : null,
),
const SizedBox(height: 12),
保持与标题字段的样式和校验逻辑一致,降低用户的学习成本。
第七步:填充片区下拉字段
DropdownButtonFormField<String>(
value: _district,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '片区',
),
items: const [
DropdownMenuItem(value: '东城区', child: Text('东城区')),
DropdownMenuItem(value: '西城区', child: Text('西城区')),
],
onChanged: (v) => setState(() => _district = v ?? '东城区'),
),
const SizedBox(height: 12),
拆分下拉项是为了避免代码过长,实际开发中可根据需要调整,核心逻辑不变。
补充剩余片区下拉项:
items: const [
DropdownMenuItem(value: '南城区', child: Text('南城区')),
DropdownMenuItem(value: '北城区', child: Text('北城区')),
DropdownMenuItem(value: '高新区', child: Text('高新区')),
],
第八步:填充提交按钮
FilledButton.icon(
onPressed: () {
if (!(_formKey.currentState?.validate() ?? false)) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已创建工单: ${_title.text} (${_district}) (模拟)')),
);
},
icon: const Icon(Icons.add_task),
label: const Text('创建'),
),
至此,完整的创建工单页面代码拆分讲解完毕,每个部分都兼顾了业务逻辑和Flutter的最佳实践。
10. 工单数据模型设计
为了规范化管理工单数据,需要定义完整的数据模型,包含工单的所有属性,并实现序列化/反序列化、拷贝等能力。
第一步:模型类基础定义
class WorkOrder {
final String id;
final String title;
final String coverCode;
final String district;
final String description;
final WorkOrderStatus status;
}
基础字段的设计思路:
- 核心标识:
id为工单唯一标识,后续用于查询、修改、删除操作 - 关联字段:
title(工单标题)、coverCode(井盖编号)、district(片区)对应表单录入字段 - 扩展字段:
description(问题描述)为可选字段,丰富工单信息 - 状态字段:
WorkOrderStatus枚举,标识工单的处理状态
第二步:补充更多业务字段
final WorkOrderPriority priority;
final DateTime createdAt;
final DateTime? updatedAt;
final String? createdBy;
final String? assignedTo;
业务字段的设计考量:
- 优先级:
WorkOrderPriority枚举,区分工单的紧急程度,便于运维人员排序处理 - 时间字段:
createdAt:必填,记录工单创建时间,不可为空updatedAt:可选,记录工单最后修改时间,null表示未修改
- 人员字段:
createdBy:创建人,关联用户系统assignedTo:处理人,后续可扩展为下拉选择
第三步:补充附件和自定义字段
final List<String> attachments;
final Map<String, dynamic> customFields;
const WorkOrder({
required this.id,
required this.title,
required this.coverCode,
required this.district,
this.description = '',
this.status = WorkOrderStatus.pending,
});
扩展字段的设计:
- 附件字段:
attachments存储图片路径列表,支持工单图片上传 - 自定义字段:
customFields适配不同场景的扩展需求,如巡检人员备注、井盖类型等 - 构造函数:
- 必填字段用
required修饰,保证实例化时的完整性 - 可选字段设置默认值,降低实例化成本
- 必填字段用
第四步:补充完整构造函数
this.priority = WorkOrderPriority.medium,
required this.createdAt,
this.updatedAt,
this.createdBy,
this.assignedTo,
this.attachments = const [],
this.customFields = const {},
});
默认值设计:
- 优先级默认:
medium(中等),符合大多数工单的紧急程度 - 集合默认值:
attachments和customFields使用空集合,避免null值 - 时间字段:仅
createdAt必填,符合业务逻辑(创建工单时必须记录创建时间)
第五步:实现从JSON反序列化
factory WorkOrder.fromJson(Map<String, dynamic> json) {
return WorkOrder(
id: json['id'] ?? '',
title: json['title'] ?? '',
coverCode: json['coverCode'] ?? '',
district: json['district'] ?? '',
description: json['description'] ?? '',
);
}
反序列化基础逻辑:
factory构造函数:用于从JSON数据创建实例,是Flutter中序列化的标准方式- 空值兜底:使用
?? ''处理JSON中缺失的字段,避免空指针异常 - 字段映射:严格对应JSON的key和模型的字段名,保证数据解析的准确性
第六步:补充枚举类型反序列化
status: WorkOrderStatus.values.firstWhere(
(e) => e.toString() == 'WorkOrderStatus.${json['status']}',
orElse: () => WorkOrderStatus.pending,
),
priority: WorkOrderPriority.values.firstWhere(
(e) => e.toString() == 'WorkOrderPriority.${json['priority']}',
orElse: () => WorkOrderPriority.medium,
),
枚举反序列化的关键:
values.firstWhere:遍历枚举值,找到与JSON字符串匹配的项- 匹配规则:将枚举值转为字符串(如
WorkOrderStatus.pending),与JSON拼接后的字符串对比 orElse兜底:当JSON中状态值不合法时,返回默认值,保证解析不崩溃
第七步:补充时间和扩展字段反序列化
createdAt: DateTime.parse(json['createdAt']),
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: null,
createdBy: json['createdBy'],
assignedTo: json['assignedTo'],
attachments: List<String>.from(json['attachments'] ?? []),
customFields: Map<String, dynamic>.from(json['customFields'] ?? {}),
时间和集合字段的解析:
- 时间解析:
DateTime.parse将JSON中的字符串时间转为DateTime对象,updatedAt做null判断 - 集合解析:
List<String>.from将JSON数组转为字符串列表Map<String, dynamic>.from将JSON对象转为Map- 空值兜底:使用
?? []/?? {}处理缺失的集合字段
第八步:实现转JSON序列化
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'coverCode': coverCode,
'district': district,
'description': description,
};
}
序列化基础逻辑:
- 字段映射:将模型字段转为JSON的key-value对,与反序列化对应
- 简洁性:只暴露必要的字段,避免冗余数据传输
第九步:补充枚举和时间序列化
'status': status.toString().split('.').last,
'priority': priority.toString().split('.').last,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt?.toIso8601String(),
枚举和时间的序列化技巧:
- 枚举处理:
toString().split('.').last提取枚举值的名称(如pending),避免传输完整的枚举字符串 - 时间处理:
toIso8601String()将DateTime转为标准格式的字符串,便于后端解析
第十步:补充剩余字段序列化
'createdBy': createdBy,
'assignedTo': assignedTo,
'attachments': attachments,
'customFields': customFields,
};
}
第十一步:实现copyWith方法
WorkOrder copyWith({
String? id,
String? title,
String? coverCode,
String? district,
String? description,
}) {
return WorkOrder(
id: id ?? this.id,
title: title ?? this.title,
coverCode: coverCode ?? this.coverCode,
district: district ?? this.district,
description: description ?? this.description,
);
}
copyWith方法的设计目的:
- 不可变对象:Flutter中推荐使用不可变对象,copyWith用于创建新实例,修改指定字段
- 字段兜底:使用
?? this.xxx保留未修改的字段值,仅更新传入的字段 - 易用性:支持部分字段修改,无需重新传入所有必填字段
第十二步:补充完整copyWith字段
WorkOrderStatus? status,
WorkOrderPriority? priority,
DateTime? createdAt,
DateTime? updatedAt,
String? createdBy,
String? assignedTo,
List<String>? attachments,
Map<String, dynamic>? customFields,
}) {
return WorkOrder(
status: status ?? this.status,
priority: priority ?? this.priority,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
createdBy: createdBy ?? this.createdBy,
assignedTo: assignedTo ?? this.assignedTo,
attachments: attachments ?? this.attachments,
customFields: customFields ?? this.customFields,
);
}
第十三步:定义枚举类型
enum WorkOrderStatus {
pending,
inProgress,
completed,
cancelled,
}
enum WorkOrderPriority {
low,
medium,
high,
urgent,
}
枚举的设计思路:
- 状态枚举:覆盖工单的全生命周期(待处理、处理中、已完成、已取消)
- 优先级枚举:区分不同紧急程度(低、中、高、紧急),便于运维调度
11. 高级表单组件
为了提升表单的复用性和用户体验,封装通用的AdvancedFormField组件,适配不同类型的输入需求。
第一步:组件参数定义
class AdvancedFormField extends StatelessWidget {
final String label;
final String? hintText;
final IconData? icon;
final bool required;
final String? Function(String?)? validator;
}
核心参数设计:
- 基础标识:
label为字段标签,hintText为输入提示,提升易用性 - 视觉增强:
icon为前缀图标,区分不同类型的字段(如标题、编号) - 校验相关:
required标识是否必填,validator为自定义校验逻辑 - 无状态组件:使用
StatelessWidget,因为组件仅负责渲染,状态由外部控制
第二步:补充更多交互参数
final void Function(String?)? onChanged;
final TextEditingController? controller;
final TextInputType keyboardType;
final int maxLines;
final bool enabled;
const AdvancedFormField({
super.key,
required this.label,
this.hintText,
this.icon,
this.required = false,
this.validator,
this.onChanged,
this.controller,
this.keyboardType = TextInputType.text,
this.maxLines = 1,
this.enabled = true,
});
交互参数的设计:
- 文本控制:
controller绑定外部控制器,实现文本双向绑定 - 输入适配:
keyboardType适配不同输入类型(如数字、文本),maxLines支持多行输入 - 状态控制:
enabled控制输入框是否可编辑,适配“查看模式”和“编辑模式” - 默认值:设置合理的默认值,降低组件使用成本
第三步:组件构建方法(基础结构)
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标签和图标区域
// 输入框区域
],
),
);
}
组件布局设计:
- 外层间距:
padding: const EdgeInsets.only(bottom: 16)与其他组件保持统一间距 - 列布局:
Column实现“标签+输入框”的垂直布局,符合表单的视觉习惯 - 左对齐:
CrossAxisAlignment.start让标签左对齐,提升可读性
第四步:构建标签和图标区域
Row(
children: [
if (icon != null) ...[
Icon(icon, size: 20, color: Theme.of(context).primaryColor),
const SizedBox(width: 8),
],
Text(
label,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
标签区域的设计:
- 图标渲染:当
icon不为null时,显示图标并增加间距,视觉上区分字段类型 - 标签样式:使用主题的
titleMedium样式,加粗处理,提升辨识度 - 行布局:
Row实现图标和标签的水平排列,布局紧凑
第五步:补充必填标识
if (required) ...[
const SizedBox(width: 4),
Text(
'*',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 16,
),
),
],
必填标识的设计:
- 红色星号:使用主题的错误色,符合用户对“必填”的认知
- 间距控制:
SizedBox(width: 4)保证星号与标签的间距,视觉舒适 - 条件渲染:仅在
required为true时显示,适配可选字段
第六步:构建输入框区域
const SizedBox(height: 8),
TextFormField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Theme.of(context).primaryColor.withOpacity(0.3),
),
),
),
),
输入框基础样式:
- 间距:
SizedBox(height: 8)分隔标签和输入框,提升可读性 - 边框设计:
OutlineInputBorder圆角8px,边框色为主题色半透明,符合现代UI设计 - 提示文本:
hintText引导用户输入,提升易用性
第七步:补充输入框状态样式
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Theme.of(context).primaryColor.withOpacity(0.3),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.error,
),
),
状态样式的设计:
- 启用状态:
enabledBorder与基础边框一致,保证视觉统一 - 聚焦状态:
focusedBorder边框加粗且为主题色,明确当前输入的字段 - 错误状态:
errorBorder为错误色,与校验失败的提示呼应
第八步:补充输入框交互和样式
filled: true,
fillColor: enabled
? Theme.of(context).colorScheme.surface
: Theme.of(context).colorScheme.surface.withOpacity(0.5),
),
validator: validator,
onChanged: onChanged,
keyboardType: keyboardType,
maxLines: maxLines,
enabled: enabled,
style: Theme.of(context).textTheme.bodyMedium,
),
交互和样式补充:
- 填充色:
filled: true启用填充色,enabled控制填充色透明度,区分可编辑状态 - 交互回调:
validator和onChanged绑定外部传入的回调,实现自定义校验和文本变更监听 - 输入适配:
keyboardType和maxLines适配不同输入场景 - 文本样式:使用主题的
bodyMedium样式,保证与应用整体风格一致
12. 工单状态管理
使用Provider实现工单的状态管理,包含创建、更新、删除、加载等核心操作,实现数据与UI的解耦。
13. 小结
创建工单页面的实现展现了 Flutter 开发的几个重要原则:
表单验证:完整的字段验证和错误提示
用户体验:加载状态、提交反馈、图片上传
状态管理:使用 Provider 管理工单数据
组件化:可复用的表单组件和图片上传组件
数据模型:完整的工单数据结构和序列化
异步处理:异步提交和错误处理
这样的设计不仅满足了创建工单的基本需求,还为后续的功能扩展(如工单编辑、状态跟踪等)奠定了坚实的基础。在实际开发中,可以根据具体业务需求调整字段和验证规则,但核心的设计思路和最佳实践是不变的。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)