Flutter 实战:random_team_generator 随机分队器的名单解析、均匀分配与鸿蒙适配解析

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

random_team_generator 是一个基于 Flutter 编写的本地随机分队工具。用户可以在文本框中输入参与者名单,应用支持按换行、逗号和分号拆分姓名;选择队伍数量后,程序会打乱名单,并按轮询取模的方式把成员尽量均匀地分配到各个队伍中。

这个项目没有后端接口,没有数据库,也没有账号系统,核心价值集中在 文本解析、状态管理、随机打乱、分组算法、条件视图切换和结果渲染 上。对于想学习 Flutter 小工具应用、鸿蒙适配验证、表单输入和列表布局的开发者来说,它是一个非常清晰的实践案例。

随机分队看似简单,实际包含了输入清洗、数据建模、边界校验、算法公平性和结果可视化等多个工程点,适合拿来训练 Flutter 的完整页面开发思路。

在这里插入图片描述

图示说明:本文围绕 Flutter 实现的本地随机分队页面展开,重点分析名单解析、分队算法、结果视图和跨端适配方式。

一、项目定位与源码概览

1.1 应用目标

random_team_generator 的目标是快速完成线下活动、课堂练习、游戏分组或小型团队协作中的随机分队需求。用户只需要完成三步:

  1. 输入参与者名单。
  2. 选择队伍数量。
  3. 点击生成按钮查看分队结果。

生成结果后,用户还可以点击 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'),
    );
  }
}

这段代码完成了三件事:

  1. 设置应用标题。
  2. 使用青绿色作为主题种子色。
  3. 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 状态流转

从用户输入到结果展示,状态流转如下:

  1. 用户在多行文本框中输入名单。
  2. 点击 Add Participants 后解析文本。
  3. _participants 更新,输入页展示成员 Chip。
  4. 用户选择 2 到 6 个队伍。
  5. 点击 Generate Teams 后生成 _teams
  6. _showTeams 变为 true,页面切换到结果视图。
  7. 点击 Shuffle Again 重新生成队伍。
  8. 点击刷新按钮调用 _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 输入页入口

_showTeamsfalse 时,页面显示输入视图:

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 结果页入口

_showTeamstrue 时,页面显示结果视图:

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 文本输入验证

鸿蒙端需要重点验证多行输入体验:

  1. 输入框聚焦是否正常。
  2. 多行换行是否正常。
  3. 软键盘弹出后页面是否可滚动。
  4. 输入较长名单时是否仍能编辑。
  5. 复制包含换行、逗号、分号的文本后解析是否一致。

14.3 Chip 交互验证

ChoiceChipChip 是页面的重要控件。适配时需要确认:

控件 验证点
ChoiceChip 选中状态、颜色、点击区域
Chip 文本展示、删除按钮、换行布局
IconButton 刷新按钮点击
ElevatedButton 添加、生成、再次随机

14.4 SnackBar 验证

SnackBar 用于提示参与者不足。鸿蒙端应验证提示是否从底部正常出现,文字是否完整,消失时机是否自然。

14.5 结果页滚动

队伍较多、参与者较多时,结果页会变长。源码使用 SingleChildScrollView,适配时需要确认滚动性能和卡片布局稳定性。

十五、测试设计与现有测试问题

15.1 当前测试状态

当前测试文件仍是 Flutter 默认计数器测试,会查找 01 和加号按钮。但实际页面是随机分队器,并没有计数器文本,也没有计数器自增逻辑。

这说明测试文件需要根据真实页面改造。

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 为什么输入名单后没有立即出现参与者

因为源码没有在 TextFieldonChanged 中解析名单,而是要求用户点击 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 表单、动态列表、条件渲染和鸿蒙适配验证。需要注意的是,源码实现的是随机均匀分配,并不包含能力均衡、历史记录或云端协作等高级功能。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

一站式 AI 云服务平台

更多推荐