Flutter 实战:random_team_generator 随机分队器的名单解析、均匀分配与鸿蒙适配解析
Flutter 实战:random_team_generator 随机分队器的名单解析、均匀分配与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
random_team_generator 是一个基于 Flutter 编写的本地随机分队工具。用户可以在文本框中输入参与者名单,应用支持按换行、逗号和分号拆分姓名;选择队伍数量后,程序会打乱名单,并按轮询取模的方式把成员尽量均匀地分配到各个队伍中。
这个项目没有后端接口,没有数据库,也没有账号系统,核心价值集中在 文本解析、状态管理、随机打乱、分组算法、条件视图切换和结果渲染 上。对于想学习 Flutter 小工具应用、鸿蒙适配验证、表单输入和列表布局的开发者来说,它是一个非常清晰的实践案例。
随机分队看似简单,实际包含了输入清洗、数据建模、边界校验、算法公平性和结果可视化等多个工程点,适合拿来训练 Flutter 的完整页面开发思路。

图示说明:本文围绕 Flutter 实现的本地随机分队页面展开,重点分析名单解析、分队算法、结果视图和跨端适配方式。
一、项目定位与源码概览
1.1 应用目标
random_team_generator 的目标是快速完成线下活动、课堂练习、游戏分组或小型团队协作中的随机分队需求。用户只需要完成三步:
- 输入参与者名单。
- 选择队伍数量。
- 点击生成按钮查看分队结果。
生成结果后,用户还可以点击 Shuffle Again 重新洗牌,或点击 AppBar 中的刷新按钮重置全部内容。
1.2 功能边界
这个项目是一个 本地演示型分队工具,源码没有实现以下功能:
- 不保存历史名单。
- 不接入云端同步。
- 不支持成员权重。
- 不支持按能力值平衡队伍。
- 不支持导出结果。
- 不支持多人协作编辑。
这些边界需要在文章中讲清楚。它的分队逻辑是随机打乱后均匀分配,不是复杂的竞赛排班或能力均衡算法。
1.3 核心文件
| 文件 | 作用 | 说明 |
|---|---|---|
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 和基础图标依赖 |
lib/main.dart |
主业务代码 | 包含入口、状态、名单解析、分队算法和页面渲染 |
test/widget_test.dart |
Widget 测试入口 | 当前仍是默认计数器测试,需要按实际页面改造 |
ohos |
鸿蒙工程目录 | 用于跨端构建与平台适配 |
1.4 技术关键词
| 技术点 | 项目体现 | 学习价值 |
|---|---|---|
StatefulWidget |
保存名单、队伍和视图状态 | 理解状态驱动 UI |
TextEditingController |
管理多行文本输入 | 掌握表单控制器生命周期 |
RegExp |
按多种分隔符拆分名单 | 学习输入解析 |
shuffle() |
打乱参与者顺序 | 理解本地随机化 |
| 取模分配 | i % _numberOfTeams |
实现简单均匀分队 |
| 条件视图 | _showTeams 切换页面 |
掌握输入页和结果页切换 |
二、运行环境与依赖结构
2.1 SDK 版本
pubspec.yaml 中声明了 Dart SDK 版本:
environment:
sdk: ^3.9.2
这说明项目可以使用较新的 Dart 语法能力,包括空安全、集合展开、const 构造、箭头函数和泛型集合。
2.2 核心依赖
项目依赖非常轻:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
| 依赖 | 用途 | 对适配的影响 |
|---|---|---|
flutter |
提供 UI 框架和运行时 | 核心依赖 |
cupertino_icons |
提供图标资源 | 当前页面主要使用 Material 图标 |
flutter_test |
Widget 测试 | 可验证输入和分队结果 |
flutter_lints |
静态规则 | 约束代码风格 |
2.3 常用命令
开发阶段可以使用:
flutter pub get
flutter analyze
flutter test
flutter run
这些命令分别用于获取依赖、静态分析、运行测试和启动应用。对于鸿蒙适配来说,建议先让 Flutter 层逻辑保持稳定,再进入平台构建。
2.4 适配难度判断
random_team_generator 没有使用摄像头、定位、蓝牙、文件系统等平台插件,因此适配重点主要集中在:
- 文本输入。
- 多行文本框。
- 软键盘弹出后的滚动体验。
ChoiceChip选中状态。SnackBar提示展示。- Material 图标资源。
三、应用入口与主题配置
3.1 main 函数
应用入口很简洁:
void main() {
runApp(const MyApp());
}
runApp 把根组件挂载到 Flutter 渲染树上。这里使用 const MyApp(),说明根组件自身没有需要运行时变化的构造参数。
3.2 MyApp 根组件
根组件负责创建 MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Random Team Generator',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const MyHomePage(title: 'Random Team Generator'),
);
}
}
这段代码完成了三件事:
- 设置应用标题。
- 使用青绿色作为主题种子色。
- 将
MyHomePage设置为首页。
3.3 主题色选择
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal)
青绿色在工具型应用中比较稳妥,既能突出主要按钮,又不会像强警示色那样带来压力。源码中主要按钮、选中芯片和结果页头部都围绕 Colors.teal 展开,视觉识别度比较统一。
3.4 页面入口关系
| 层级 | 类或函数 | 职责 |
|---|---|---|
| 启动层 | main() |
启动 Flutter 应用 |
| 应用层 | MyApp |
配置主题和首页 |
| 页面层 | MyHomePage |
接收标题并创建状态 |
| 状态层 | _MyHomePageState |
管理名单、队伍数量、结果和视图 |
四、状态字段设计
4.1 核心状态
页面状态集中在 _MyHomePageState 中:
final TextEditingController _namesController = TextEditingController();
final List<String> _participants = [];
List<List<String>> _teams = [];
int _numberOfTeams = 2;
bool _showTeams = false;
4.2 字段职责
| 字段 | 类型 | 初始值 | 作用 |
|---|---|---|---|
_namesController |
TextEditingController |
新控制器 | 管理名单输入框 |
_participants |
List<String> |
空列表 | 保存解析后的参与者 |
_teams |
List<List<String>> |
空列表 | 保存生成后的队伍 |
_numberOfTeams |
int |
2 |
当前选择的队伍数量 |
_showTeams |
bool |
false |
控制输入页与结果页切换 |
4.3 状态流转
从用户输入到结果展示,状态流转如下:
- 用户在多行文本框中输入名单。
- 点击
Add Participants后解析文本。 _participants更新,输入页展示成员 Chip。- 用户选择 2 到 6 个队伍。
- 点击
Generate Teams后生成_teams。 _showTeams变为true,页面切换到结果视图。- 点击
Shuffle Again重新生成队伍。 - 点击刷新按钮调用
_reset()清空状态。
4.4 控制器释放
源码正确释放了文本控制器:
void dispose() {
_namesController.dispose();
super.dispose();
}
TextEditingController 持有文本状态和监听能力,页面销毁时释放它是 Flutter 表单开发的基本规范。
五、名单输入与解析逻辑
5.1 多行输入框
输入区域使用 TextField:
TextField(
controller: _namesController,
maxLines: 8,
decoration: InputDecoration(
hintText: 'John\nJane\nBob\nAlice...',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
)
maxLines: 8 让用户可以一次输入多行名单,适合课堂、活动和会议场景。
5.2 解析入口
点击 Add Participants 后会调用 _addParticipants():
void _addParticipants() {
final names = _namesController.text
.split(RegExp(r'[\n,;]'))
.map((n) => n.trim())
.where((n) => n.isNotEmpty)
.toList();
setState(() {
_participants.clear();
_participants.addAll(names);
_showTeams = false;
});
}
这段代码完成了输入解析、空白清理、空项过滤和状态刷新。
5.3 分隔符规则
split(RegExp(r'[\n,;]'))
该正则支持三类分隔符:
| 分隔符 | 示例 | 适用输入方式 |
|---|---|---|
| 换行 | John 换行 Jane |
从表格或名单复制 |
| 逗号 | John,Jane,Bob |
横向文本输入 |
| 分号 | John;Jane;Bob |
部分办公文本习惯 |
5.4 清理与过滤
.map((n) => n.trim())
.where((n) => n.isNotEmpty)
trim() 会去除首尾空白,where 会过滤空字符串。这样即使用户输入多余换行或连续分隔符,也不会生成空成员。
六、队伍数量选择
6.1 ChoiceChip 选择器
源码使用 ChoiceChip 提供队伍数量选择:
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [2, 3, 4, 5, 6].map((num) {
return ChoiceChip(
label: Text('$num'),
selected: _numberOfTeams == num,
onSelected: (_) => setState(() => _numberOfTeams = num),
selectedColor: Colors.teal.shade200,
);
}).toList(),
)
6.2 可选范围
| 可选队伍数 | 说明 |
|---|---|
| 2 | 默认值,适合对抗分组 |
| 3 | 适合小组讨论 |
| 4 | 适合课堂活动 |
| 5 | 适合多人活动 |
| 6 | 当前源码支持的最大值 |
6.3 为什么使用 Chip
与下拉框相比,ChoiceChip 更适合少量离散选项:
- 所有选项一眼可见。
- 点击路径短。
- 选中状态清晰。
- 页面交互更轻。
6.4 状态更新
onSelected: (_) => setState(() => _numberOfTeams = num)
每次点击 Chip 都会更新 _numberOfTeams。如果用户已经添加参与者但还没有生成队伍,后续生成会使用新的队伍数量。
七、参与者列表展示与删除
7.1 条件显示参与者区域
参与者区域只有在 _participants 非空时显示:
if (_participants.isNotEmpty) ...[
const Text('Participants', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _participants.map((name) {
return Chip(
label: Text(name),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
_participants.remove(name);
});
},
);
}).toList(),
),
]
7.2 Wrap 的价值
Wrap 适合展示数量不固定的成员标签:
| 参数 | 作用 |
|---|---|
spacing |
横向标签间距 |
runSpacing |
换行后的纵向间距 |
children |
成员 Chip 列表 |
当成员较多时,Wrap 会自动换行,比固定 Row 更适合名单展示。
7.3 删除成员
每个 Chip 都提供删除按钮:
onDeleted: () {
setState(() {
_participants.remove(name);
});
}
这个操作会从 _participants 中删除第一个匹配的姓名,并刷新页面。
7.4 重名成员的边界
当前删除逻辑使用 _participants.remove(name)。如果名单中存在两个完全相同的姓名,删除其中一个 Chip 时会移除第一个匹配项。对于演示项目来说可以接受;如果用于真实活动,可以给成员增加唯一 ID。
当业务允许重名时,不建议只用姓名作为唯一标识。更稳妥的做法是给每个成员生成内部 ID,再用 ID 执行删除和分组。
八、随机分队算法
8.1 生成入口
点击 Generate Teams 会调用 _generateTeams():
void _generateTeams() {
if (_participants.length < _numberOfTeams) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Need more participants for teams')),
);
return;
}
final shuffled = List<String>.from(_participants)..shuffle();
final teams = List.generate(_numberOfTeams, (_) => <String>[]);
for (int i = 0; i < shuffled.length; i++) {
teams[i % _numberOfTeams].add(shuffled[i]);
}
setState(() {
_teams = teams;
_showTeams = true;
});
}
这段代码是应用最核心的业务逻辑。
8.2 参与者数量校验
if (_participants.length < _numberOfTeams) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Need more participants for teams')),
);
return;
}
如果参与者数量少于队伍数量,至少会有队伍为空。源码用 SnackBar 给出提示并提前返回。
8.3 打乱名单
final shuffled = List<String>.from(_participants)..shuffle();
这里先复制 _participants,再对副本调用 shuffle()。这样做不会直接打乱原始参与者列表,结果页可以基于随机副本生成队伍。
8.4 初始化队伍
final teams = List.generate(_numberOfTeams, (_) => <String>[]);
List.generate 根据队伍数量创建多个空列表。每个空列表代表一个队伍。
8.5 取模均匀分配
for (int i = 0; i < shuffled.length; i++) {
teams[i % _numberOfTeams].add(shuffled[i]);
}
核心思想是让成员按照下标轮流进入不同队伍:
| 成员下标 | 队伍下标表达式 | 分配队伍 |
|---|---|---|
| 0 | 0 % 3 |
第 1 队 |
| 1 | 1 % 3 |
第 2 队 |
| 2 | 2 % 3 |
第 3 队 |
| 3 | 3 % 3 |
第 1 队 |
| 4 | 4 % 3 |
第 2 队 |
| 5 | 5 % 3 |
第 3 队 |
这种算法能保证各队人数差最多为 1。
九、输入视图结构
9.1 输入页入口
当 _showTeams 为 false 时,页面显示输入视图:
body: _showTeams ? _buildTeamsView() : _buildInputView(),
_buildInputView() 包含名单输入、队伍数量选择、参与者标签和生成按钮。
9.2 滚动容器
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 输入卡片、队伍数量卡片、参与者区域
],
),
);
SingleChildScrollView 可以避免小屏设备上内容溢出。尤其是多行输入框和软键盘同时出现时,滚动能力非常重要。
9.3 添加按钮
ElevatedButton.icon(
onPressed: _addParticipants,
icon: const Icon(Icons.add),
label: const Text('Add Participants'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
padding: const EdgeInsets.all(16),
),
)
按钮使用图标和文本组合,清楚表达“把文本框内容解析为参与者列表”的动作。
9.4 生成按钮
ElevatedButton.icon(
onPressed: _generateTeams,
icon: const Icon(Icons.shuffle),
label: const Text('Generate Teams'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
padding: const EdgeInsets.all(16),
),
)
该按钮只在 _participants.isNotEmpty 时显示,避免用户在没有成员时直接生成。
十、结果视图结构
10.1 结果页入口
当 _showTeams 为 true 时,页面显示结果视图:
Widget _buildTeamsView() {
final teamColors = [
Colors.blue,
Colors.red,
Colors.green,
Colors.orange,
Colors.purple,
Colors.teal,
];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 统计卡片、队伍卡片、再次随机按钮
],
),
);
}
10.2 统计卡片
Card(
color: Colors.teal.shade50,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Icon(Icons.groups, size: 48, color: Colors.teal),
const SizedBox(height: 8),
Text(
'$_numberOfTeams Teams',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
Text(
'${_participants.length} participants',
style: TextStyle(color: Colors.grey.shade600),
),
],
),
),
)
统计卡片告诉用户当前结果包含多少队伍、多少参与者。
10.3 队伍颜色
源码准备了 6 种颜色:
final teamColors = [
Colors.blue,
Colors.red,
Colors.green,
Colors.orange,
Colors.purple,
Colors.teal,
];
队伍数量最多也是 6,因此每个队伍都能获得一个基础颜色。
10.4 队伍卡片生成
...List.generate(_teams.length, (index) {
final team = _teams[index];
final color = teamColors[index % teamColors.length];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 队伍标题和成员标签
],
),
),
);
})
List.generate 根据 _teams.length 动态生成队伍卡片,适合队伍数量可变的场景。
十一、队伍卡片细节
11.1 队伍序号
每个队伍使用 CircleAvatar 展示序号:
CircleAvatar(
backgroundColor: color,
child: Text(
'${index + 1}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
)
index + 1 将从 0 开始的数组下标转换成人更容易理解的队伍编号。
11.2 队伍标题
Text(
'Team ${index + 1}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
)
标题和圆形序号配合,能让结果页面保持清晰分组。
11.3 成员标签
成员使用 Container 渲染:
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withAlpha(30),
borderRadius: BorderRadius.circular(20),
),
child: Text(name, style: TextStyle(color: color)),
)
每个成员标签使用队伍颜色的浅色背景和深色文字,视觉上能明显归属到对应队伍。
11.4 再次随机
结果页底部提供 Shuffle Again:
ElevatedButton.icon(
onPressed: _generateTeams,
icon: const Icon(Icons.shuffle),
label: const Text('Shuffle Again'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
padding: const EdgeInsets.all(16),
),
)
它复用 _generateTeams(),不需要重新输入名单,只要重新打乱和分配即可。
十二、重置流程
12.1 AppBar 刷新按钮
页面右上角有刷新按钮:
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _reset,
),
],
该按钮用于清空当前分队流程,回到初始状态。
12.2 重置函数
void _reset() {
setState(() {
_participants.clear();
_teams.clear();
_showTeams = false;
_namesController.clear();
});
}
12.3 重置内容
| 重置对象 | 重置方式 | 结果 |
|---|---|---|
| 参与者列表 | _participants.clear() |
清空成员 |
| 队伍列表 | _teams.clear() |
清空结果 |
| 结果视图 | _showTeams = false |
回到输入页 |
| 输入框 | _namesController.clear() |
清空文本 |
12.4 队伍数量不会重置
源码中的 _reset() 没有把 _numberOfTeams 改回 2。因此用户如果选过 5 队,点击刷新后仍保持 5 队选择。这个行为不一定是错误,只是当前设计选择。
十三、边界情况分析
13.1 空输入
如果输入框为空,点击 Add Participants 后解析出的 names 是空列表,_participants 也会变为空。此时不会展示参与者区域和生成按钮。
13.2 分隔符过多
输入如下内容:
John,,Jane;
Bob
经过 trim() 和 isNotEmpty 过滤后,不会生成空姓名。
13.3 参与者少于队伍数
如果参与者数量小于队伍数量,_generateTeams() 会展示提示:
const SnackBar(content: Text('Need more participants for teams'))
并通过 return 阻止继续生成。
13.4 人数不能整除队伍数
当人数不能整除队伍数时,取模分配会让部分队伍多 1 人。例如 10 人分 3 队时,队伍人数会接近 4、3、3。
13.5 重名成员
源码允许输入重名成员,因为成员本质上只是字符串。展示和分配都能运行,但删除时会按字符串删除第一个匹配项。如果真实业务需要区分同名人员,应增加唯一标识。
十四、鸿蒙适配关注点
14.1 适配优势
这个项目对鸿蒙适配比较友好:
- 没有平台插件。
- 没有网络请求。
- 没有本地持久化。
- 没有复杂动画。
- 核心算法是纯 Dart 代码。
14.2 文本输入验证
鸿蒙端需要重点验证多行输入体验:
- 输入框聚焦是否正常。
- 多行换行是否正常。
- 软键盘弹出后页面是否可滚动。
- 输入较长名单时是否仍能编辑。
- 复制包含换行、逗号、分号的文本后解析是否一致。
14.3 Chip 交互验证
ChoiceChip 和 Chip 是页面的重要控件。适配时需要确认:
| 控件 | 验证点 |
|---|---|
ChoiceChip |
选中状态、颜色、点击区域 |
Chip |
文本展示、删除按钮、换行布局 |
IconButton |
刷新按钮点击 |
ElevatedButton |
添加、生成、再次随机 |
14.4 SnackBar 验证
SnackBar 用于提示参与者不足。鸿蒙端应验证提示是否从底部正常出现,文字是否完整,消失时机是否自然。
14.5 结果页滚动
队伍较多、参与者较多时,结果页会变长。源码使用 SingleChildScrollView,适配时需要确认滚动性能和卡片布局稳定性。
十五、测试设计与现有测试问题
15.1 当前测试状态
当前测试文件仍是 Flutter 默认计数器测试,会查找 0、1 和加号按钮。但实际页面是随机分队器,并没有计数器文本,也没有计数器自增逻辑。
这说明测试文件需要根据真实页面改造。
15.2 初始页面测试
可以先验证输入页默认内容:
testWidgets('shows input page initially', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Enter Participants'), findsOneWidget);
expect(find.text('Number of Teams'), findsOneWidget);
expect(find.text('Add Participants'), findsOneWidget);
});
15.3 添加参与者测试
testWidgets('adds participants from text field', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.enterText(find.byType(TextField), 'John\nJane\nBob');
await tester.tap(find.text('Add Participants'));
await tester.pump();
expect(find.text('John'), findsOneWidget);
expect(find.text('Jane'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
});
15.4 生成队伍测试
testWidgets('generates teams after adding participants', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.enterText(find.byType(TextField), 'John\nJane\nBob\nAlice');
await tester.tap(find.text('Add Participants'));
await tester.pump();
await tester.tap(find.text('Generate Teams'));
await tester.pump();
expect(find.text('2 Teams'), findsOneWidget);
expect(find.text('4 participants'), findsOneWidget);
expect(find.text('Shuffle Again'), findsOneWidget);
});
15.5 参与者不足测试
testWidgets('shows message when participants are fewer than teams', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.enterText(find.byType(TextField), 'John');
await tester.tap(find.text('Add Participants'));
await tester.pump();
await tester.tap(find.text('Generate Teams'));
await tester.pump();
expect(find.text('Need more participants for teams'), findsOneWidget);
});
十六、代码质量与可维护性
16.1 当前实现优点
random_team_generator 的实现清晰,主要优点包括:
- 单文件即可读懂完整流程。
- 状态字段命名直观。
- 输入解析链式调用简洁。
- 分队算法短小明确。
- 输入页和结果页拆成独立方法。
- 控制器生命周期处理正确。
16.2 可抽离的参与者模型
如果继续扩展,可以把参与者从字符串升级为对象:
class Participant {
const Participant({
required this.id,
required this.name,
});
final String id;
final String name;
}
这样可以解决重名删除和后续权重扩展问题。
16.3 可抽离的分队服务
分队算法也可以抽成纯函数:
List<List<String>> generateTeams({
required List<String> participants,
required int teamCount,
}) {
final shuffled = List<String>.from(participants)..shuffle();
final teams = List.generate(teamCount, (_) => <String>[]);
for (var i = 0; i < shuffled.length; i++) {
teams[i % teamCount].add(shuffled[i]);
}
return teams;
}
抽成纯函数后,算法测试会更简单,也能降低 Widget 测试压力。
16.4 可配置队伍范围
当前队伍数量写死为 [2, 3, 4, 5, 6]。如果业务需要,可以把范围配置化:
const teamCountOptions = [2, 3, 4, 5, 6, 7, 8];
然后 UI 直接基于配置生成选项。
十七、性能与体验优化
17.1 算法复杂度
分队算法主要包含三步:
| 步骤 | 复杂度 | 说明 |
|---|---|---|
| 复制名单 | O(n) | 创建副本 |
| 打乱名单 | O(n) | Fisher-Yates 类随机洗牌 |
| 分配队伍 | O(n) | 遍历每个成员 |
对于小型活动名单,这个性能完全足够。
17.2 长名单输入
如果用户一次输入几百个名字,页面仍能运行,但 Chip 展示会变长。真实产品可以考虑:
- 增加参与者数量统计。
- 使用列表替代大量 Chip。
- 支持搜索成员。
- 支持批量清理重复项。
17.3 结果稳定性
每次点击 Shuffle Again 都会重新调用 shuffle(),结果可能不同。随机性来自本地运行时,不保证可复现。如果需要可复现结果,可以引入可控随机种子。
17.4 交互提示
当前只有参与者不足时提示。还可以在空输入时提示用户输入名单,但源码没有实现这一点,文章分析时应保持真实。
十八、常见问题与优化建议
18.1 为什么输入名单后没有立即出现参与者
因为源码没有在 TextField 的 onChanged 中解析名单,而是要求用户点击 Add Participants。只有按钮触发 _addParticipants() 后,参与者列表才会更新。
18.2 为什么队伍数量只能选 2 到 6
因为源码中选择器基于固定数组 [2, 3, 4, 5, 6] 生成。如果需要更多队伍,需要扩展这个数组或改成数字输入控件。
18.3 为什么重新随机不需要重新输入名单
结果页的 Shuffle Again 复用 _participants,只重新打乱并生成 _teams。原始参与者列表仍保存在状态中。
18.4 为什么参与者不足时不能生成队伍
源码要求 _participants.length >= _numberOfTeams。这样可以避免出现空队伍,让分队结果更直观。
18.5 为什么重置后队伍数量没有回到 2
_reset() 没有修改 _numberOfTeams。这意味着刷新会清空名单和结果,但保留用户当前选择的队伍数量。
18.6 能不能保证绝对公平
当前算法能保证人数尽量均匀,但不考虑成员能力、角色、性别、部门等约束。如果需要“能力均衡”,需要额外的数据模型和分配策略。
十九、核心知识点速查
19.1 Widget 速查
| Widget | 使用位置 | 作用 |
|---|---|---|
MaterialApp |
根组件 | 应用配置 |
Scaffold |
页面骨架 | AppBar 和 Body |
TextField |
输入卡片 | 输入多行名单 |
ChoiceChip |
队伍数量 | 选择分队数量 |
Chip |
参与者展示 | 显示和删除成员 |
Wrap |
标签布局 | 自动换行 |
SnackBar |
错误提示 | 展示参与者不足 |
CircleAvatar |
队伍序号 | 强化分组识别 |
19.2 方法速查
| 方法 | 作用 |
|---|---|
_addParticipants() |
解析输入文本并更新参与者列表 |
_generateTeams() |
校验人数、打乱名单、生成队伍 |
_reset() |
清空名单、队伍和输入框 |
_buildInputView() |
构建输入页面 |
_buildTeamsView() |
构建结果页面 |
dispose() |
释放输入控制器 |
19.3 数据结构速查
| 数据结构 | 示例 | 含义 |
|---|---|---|
List<String> |
['John', 'Jane'] |
参与者列表 |
List<List<String>> |
[['John'], ['Jane']] |
队伍列表 |
int |
2 |
队伍数量 |
bool |
true |
是否显示结果页 |
二十、扩展方向
20.1 功能扩展
可以基于当前项目继续增加:
- 导出分队结果。
- 保存历史名单。
- 支持成员权重。
- 支持指定成员不在同队。
- 支持队伍命名。
- 支持复制结果到剪贴板。
20.2 UI 扩展
界面层可以增强:
- 增加空状态。
- 增加参与者数量统计。
- 增加分队动画。
- 增加横屏适配。
- 增加深色模式。
20.3 算法扩展
如果要从“随机分队”升级为“智能分队”,可以考虑:
- 按能力值均衡。
- 按角色约束分配。
- 按部门打散。
- 按历史同队次数避让。
- 使用可复现随机种子。
20.4 跨端实践价值
random_team_generator 适合作为 Flutter 适配鸿蒙的小型样例。它覆盖了多行输入、Chip 选择、标签删除、SnackBar、滚动容器和动态结果列表,能帮助开发者验证常见 UI 能力在鸿蒙端的表现。
总结
random_team_generator 用简洁的 Flutter 代码实现了一个本地随机分队工具。它通过 _namesController 管理名单输入,通过 _participants 保存参与者,通过 _numberOfTeams 控制队伍数量,通过 _generateTeams() 完成随机打乱和取模分配,并通过 _showTeams 在输入视图和结果视图之间切换。
从工程角度看,这个项目最值得学习的是 输入解析 + 状态驱动 + 简单算法可视化 的组合方式。它没有复杂依赖,适合用来练习 Flutter 表单、动态列表、条件渲染和鸿蒙适配验证。需要注意的是,源码实现的是随机均匀分配,并不包含能力均衡、历史记录或云端协作等高级功能。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)