【Flutter】学习 stream 的操作,健身app动作演示功能实现
先看效果图👀👀👀
篇幅有点长,不想看文章的同学可以移步至 github.com/Astra1427/x…
一、准备工作✔✔✔
首先我们分析一下图中所有出现的情况,大致可归纳成以下几种(TrainingPartType), 同时编写一个任务类(TrainingTaskItem):
TrainingTaskItem类 和 TrainingPartType 枚举(点我展开代码)
class TrainingTaskItem extends LinkedListEntry<TrainingTaskItem> {
// 每个任务都有一个执行时间
int delayTime;
TrainingPartType type;
int value;
int? tag;
TrainingTaskItem(this.delayTime, this.type, this.value, {this.tag});
}
enum TrainingPartType {
/// 动作 ‘一’
training_1,
/// 动作 ‘二’
training_2,
/// 读秒动作
readSecond,
/// 开始倒计时
countDown,
/// 组间休息
sleep,
/// 开始
start,
/// 完毕
finish,
/// 暂停
pause,
/// 新的一组
newGroup
}
为了方便理解,我们先从最简单情况考虑,即:用户在点击“设置好了,开始训练”按钮后,就不做任何操作了。
再把上述的情况看作是一个接着一个的任务链表,就像这样:
(图 1)
从第一排开始看: countDown:3、2、1 就代表着开始训练之前的倒计时,从3开始倒数,那么就有三个countDown任务(taskItem),再接上一个start 任务(方便拓展更多功能,如:开始语音播报、开始文字提醒)
第二排可分解成 训练动作(Training_1、Training_2)和 完成一组的动作后开启新的一组(newGroup)以及组间休息(Sleep)最后再加上start。训练动作的value值可用于确定当前的动作做了几个,newGroup 可提醒订阅者当前组数+1,Sleep任务和countDown任务是一样的,如组间休息10秒,那么就有10个Sleep任务,value值等于倒计时。 (冒号后面的数字代表 TrainingTaskItem.value值。)
最后当组数全部做完的时候,我们需要一个finish任务来提醒订阅者训练已经完成。
二、TrainingProvider的设计
我编写了一个provider,这样不仅能将界面代码和逻辑代码分离,同时还能考虑需要多个订阅流的情况
我们先简单了解一下stream在flutter中是怎样使用的🤔:
1、创建一个StreamController
var trainingTaskStreamController = StreamController<TrainingTaskItem>.broadcast();
2、创建 流的订阅对象
trainingTaskStreamController.stream.listen((event) {
//todo something...
debugPrint(event.toString());
});
3、向流添加数据
trainingTaskStreamController.sink.add(currentTrainingTaskItem!);
这样,每向流添加一次数据,订阅器中的监听事件就会执行一次,打印这个对象的toString();
想要深入学习stream的同学可以跳转这篇文章看下:juejin.cn/post/684490…
好了,简单的教学已经结束了,让我们来实现功能吧😁。(实际上最核心的代码就是这么简单的几行代码实现的。)
首先第一步,我们要生成一个像图 1一样的任务链表。
1、根据 AppConfigModel 和 StandardDTO 生成任务链表
(点我查看代码)
/// 根据 AppConfigModel 和 StandardDTO 生成任务链表
void generateTrainingTaskItemQueue(
AppConfigModel configModel, StandardDTO standardModel) {
//生成 倒计时
for (int i = 3; i > 0; i--) {
taskItems.add(TrainingTaskItem(1000, TrainingPartType.countDown, i));
}
//生成 动作/组
for (int i = 1; i <= standardModel.groupNumber; i++) {
//生成动作
for (int j = 1; j <= standardModel.number; j++) {
if (standardModel.getSkillStyleDTO()?.traningType == true) {
taskItems.add(TrainingTaskItem(1000, TrainingPartType.readSecond, j));
} else {
//为了观感 先显示 动作2
taskItems.add(TrainingTaskItem(
configModel.downNumberSecond, TrainingPartType.training_2, j));
taskItems.add(TrainingTaskItem(
configModel.upNumberSecond, TrainingPartType.training_1, j));
}
}
//生成 组
taskItems.add(TrainingTaskItem(50, TrainingPartType.newGroup, i));
//如果i == 所有组数,那么生成一个 '完毕' 的task
if (standardModel.groupNumber == i) {
//finish
taskItems.add(TrainingTaskItem(500, TrainingPartType.finish, 0));
debugPrint('finish...');
//不用再添加组间休息的task了,所以直接break;
break;
}
//生成 组间休息的task
for (int j = configModel.sleepSecond; j > 0; j--) {
//tag传组数
taskItems
.add(TrainingTaskItem(1000, TrainingPartType.sleep, j, tag: i));
}
taskItems.add(TrainingTaskItem(50, TrainingPartType.start, 0));
}
//设置当前的动作为 排在第一位的 '动作2'任务
currentAction = taskItems.firstWhere((element) =>
element.type == TrainingPartType.training_2 ||
element.type == TrainingPartType.readSecond);
//设置当前任务为 任务链表的首个任务
currentTrainingTaskItem = taskItems.first;
}
AppConfigModel和StandardDTO是从外部传进来的,结构如下:
(为了方便大家快速理解,我只列举重要的属性)
class AppConfigModel{
/// 组间休息的时间
int sleepSecond ;
/// Down动作时间 毫秒
int downNumberSecond ;
/// Up动作时间 毫秒
int upNumberSecond ;
}
class StandardDTO{
/// 组数
int groupNumber;
/// 每组的动作数
int number;
}
简而言之就是通过循环groupNumber和number来生成所有的动作任务
(只有training_1,training_2,readSecond 属于动作任务),然后再生成countDown任务、Sleep任务、start任务、finish任务。最后把它们按执行顺序连接起来,得到一个 taskItems。
这里我们注意一下 currentTrainingTaskItem 和 currentAction ,将 currentTrainingTaskItem设为第一个taskItems的第一个item,后续我们就是通过 currentTrainingTaskItem 来对链表进行迭代的。同时 currentAction 用于确定当前的动作任务,这个我们后面再讲。
2、生成倒计时(countDown)任务
点击查看代码
/// 生成倒计时(countDown)任务
List<TrainingTaskItem> generateCountDown() {
//生成之前先移除taskItems里已存在的倒计时任务、start任务,
// 如果当前的任务类型为sleep那么就能移除所有与当前任务tag值相同的sleep任务,这样可以实现跳过当前的组间休息时间。
var removedItems = taskItems
.where((element) =>
element.type == TrainingPartType.countDown ||
//element.type == TrainingPartType.sleep || 不能直接移除所有sleep,因为这样会导致还没执行的sleep也被移除。
(currentTrainingTaskItem?.type == TrainingPartType.sleep &&
element.type == TrainingPartType.sleep &&
currentTrainingTaskItem?.tag == element.tag) ||
element.type == TrainingPartType.start)
.toList();
debugPrint(
'current::: ${currentTrainingTaskItem?.type}______${currentTrainingTaskItem?.value}');
//如果当前的任务类型为countDown 或者 sleep,那么在移除removedItems之前得先确定移除后的'当前任务'
//使用场景为:当已经在倒计时了,用户又需要暂停的时候。
if (removedItems.isNotEmpty &&
(currentTrainingTaskItem?.type == TrainingPartType.countDown ||
currentTrainingTaskItem?.type == TrainingPartType.sleep)) {
//如果removedItems存在上一个任务就把removedItems的上一个任务设为当前任务,
currentTrainingTaskItem = removedItems.first.previous;
//如果removedItems不存在上一个任务,就把removedItems的下一个任务设为当前任务
currentTrainingTaskItem ??= removedItems.last.next;
}
//从taskItems中移除removedItems
for (var entry in removedItems) {
taskItems.remove(entry);
}
//生成新的倒计时任务
List<TrainingTaskItem> items = [];
for (int i = 3; i > 0; i--) {
items.add(TrainingTaskItem(1000, TrainingPartType.countDown, i));
}
//返回出去
return items;
}
返回一个像 图一 第一排 一样的countDown任务链表,在生成之前先移除taskItems中已存在的countDown任务sleep任务,这是为了防止用户在countDown任务执行的时候再次暂停然后继续而产生重复的countDown,移除sleep任务是考虑到用户可能在组间休息的时候也会暂停,那么这时候就会出现
(sleep:10,sleep:9,sleep:8,[pause],countDown:3,countDown:2,countDown:1,start,sleep:7,sleep:6,...)
这种情况,可以看见当用户在组间休息的时候暂停继续,如果不移除当前的sleep任务,那么在countDown结束后又会继续sleep,这看起来就就像是倒计时数到1之后又从10开始数一遍,所以我们选择直接移除当前sleep任务,同时还能起到跳过组间休息时间的功能。
3、切换暂停
点击查看代码
/// 切换暂停
void switchPause() {
//切换暂停
isPause = !isPause;
notifyListeners();
if (isPause) {
// currentTrainingTaskItem
// ?.insertAfter(TrainingTaskItem(50, TrainingPartType.pause, 0));
//暂停对流的监听
_subscription?.pause();
} else {
//生成倒计时任务
var countDownTasks = generateCountDown();
//将倒计时任务插入到当前动作任务(currentAction)之前。
for (var element in countDownTasks) {
currentAction?.insertBefore(element);
}
//将'开始'任务插入到currentAction 之前。
currentAction
?.insertBefore(TrainingTaskItem(500, TrainingPartType.start, 0));
//设置当前任务为倒计时任务的首个(也就是 倒计时:3)
currentTrainingTaskItem = countDownTasks.first;
//恢复对流的监听
_subscription?.resume();
}
}
大家可以看见,我并没有把pause任务插入到链表中,而是选择使用_subscription?.pause();
来控制暂停和继续,这是为了能够用户在暂停的时候能够及时的获得反馈,这个可以看startTraining的代码。
当isPause = falses 时,就生成一个countDownTasks,将其插入到当前动作任务之前,同时设置当前任务为倒计时的首个,最后恢复subscription。
4、开始训练
点击查看代码
/// 开始训练(往流中添加task)
Future startTraining() async {
//重置暂停和完毕状态
isPause = false;
isFinish = false;
//迭代链表,从当前任务开始
while (currentTrainingTaskItem != null) {
if (trainingTaskStreamController.isClosed) {
break;
}
if (isPause) {
//如果暂停了,每200毫秒检测一次暂停状态,数字越小反应越快,但对性能的要求也越高
await Future.delayed(const Duration(milliseconds: 200));
continue;
}
if (isFinish) {
break;
}
//将当前任务放入流中
trainingTaskStreamController.sink.add(currentTrainingTaskItem!);
if (currentTrainingTaskItem != null) {
//每个任务都有的执行时间
await Future.delayed(
Duration(milliseconds: currentTrainingTaskItem!.delayTime));
}
if (currentTrainingTaskItem != null) {
if (currentTrainingTaskItem!.next?.type != TrainingPartType.countDown &&
currentTrainingTaskItem!.next?.type != TrainingPartType.sleep &&
currentTrainingTaskItem!.next?.type != TrainingPartType.start) {
//设置当前动作任务
currentAction = currentTrainingTaskItem!.next;
}
//设置当前任务
currentTrainingTaskItem = currentTrainingTaskItem!.next;
}
notifyListeners();
}
}
5、对流的订阅以及执行相应的任务
点击查看代码
StreamSubscription<TrainingTaskItem> openSubscription() {
if (trainingTaskStreamController.isClosed) {
trainingTaskStreamController =
StreamController<TrainingTaskItem>.broadcast();
}
//打开并返回一个对流的监听器
return _subscription = trainingTaskStreamController.stream.listen((event) {
debugPrint('${event.type}______${event.value}');
//执行task
trainingTaskItemAction(event);
notifyListeners();
});
}
/// 根据task的type执行对应的操作
void trainingTaskItemAction(TrainingTaskItem event) {
//初始化数据
initData();
switch (event.type) {
case TrainingPartType.training_1:
currentNumber = event.value;
isImg1 = true;
//TODO play 'one' audio
break;
case TrainingPartType.training_2:
// currentNumber = event.value;
isImg1 = false;
//TODO play 'two' audio
break;
case TrainingPartType.readSecond:
//TODO play 'value' audio
//play(value)
currentNumber = event.value;
break;
case TrainingPartType.countDown:
//TODO play 'value' audio
//play(value)
countDownSecond = event.value;
isCountDown = true;
break;
case TrainingPartType.sleep:
countDownSecond = event.value;
isSleep = true;
currentNumber = 0;
break;
case TrainingPartType.start:
//TODO play 'start' audio
break;
case TrainingPartType.finish:
//TODO play 'finish' audio
isFinish = true;
break;
case TrainingPartType.pause:
isPause = true;
break;
case TrainingPartType.newGroup:
currentGroupNumber = event.value;
break;
}
}
void initData() {
isCountDown = false;
isSleep = false;
}
这段代码就很好理解了,就是打开一个对流的订阅并返回订阅对象给调用方使用,以及执行taskItem相应的任务。
6、数据回收
点击查看代码
void disposeData() {
taskItems.clear();
_subscription?.cancel();
trainingTaskStreamController.close();
currentTrainingTaskItem = null;
countDownSecond = 3;
isImg1 = true;
isCountDown = false;
isPause = false;
isSleep = false;
isFinish = false;
currentGroupNumber = 0;
currentNumber = 0;
// notifyListeners();
}
三、界面实现
1、provider数据初始化
点击查看代码
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
var appProvider = Provider.of<AppConfigProvider>(context, listen: false);
var configModel =
appProvider.appConfigModel ?? AppConfigModel.getDefault();
trainingProvider =
Provider.of<StandardTrainingProvider>(context, listen: false);
trainingProvider.generateTrainingTaskItemQueue(configModel,widget.standardModel);
subscription = trainingProvider.openSubscription();
await trainingProvider.startTraining();
});
}
2、使用Consumer构建界面
点击查看代码
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
'${widget.standardModel.getSkillStyleDTO()?.name ?? ''} ${widget.standardModel.toString()}'),
),
body: Center(
child: Consumer2<StandardTrainingProvider, AppConfigProvider>(
builder: (context, trainingProvider, appProvider, child) {
return GestureDetector(
onTap: () {
if (trainingProvider.isFinish) {
return;
}
trainingProvider.switchPause();
},
child: SizedBox(
height: double.infinity,
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Visibility(
visible: trainingProvider.isImg1,
child: Image.asset(
'images/${styleModel.img1Url}.png',
fit: BoxFit.fill,
)),
Visibility(
visible: !trainingProvider.isImg1,
child: Image.asset(
'images/${styleModel.img2Url}.png',
fit: BoxFit.fill,
)),
Text(
'${trainingProvider.currentNumber} / ${widget.standardModel.number}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 30, color: Colors.white),
),
Visibility(
visible: trainingProvider.isCountDown ||
trainingProvider.isSleep,
child: buildCountDown(trainingProvider)),
Visibility(
visible: trainingProvider.isPause,
child: buildPausePanel(trainingProvider)),
Visibility(
visible: trainingProvider.isFinish,
child: buildFinishPanel()),
Positioned(
top: 0,
left: 0,
child: Text(
'动作数:${trainingProvider.currentNumber}/${widget.standardModel.number}',
style: const TextStyle(fontSize: 20, color: Colors.white),
)),
Positioned(
top: 0,
right: 0,
child: Text(
'组数:${trainingProvider.currentGroupNumber}/${widget.standardModel.groupNumber}',
style: const TextStyle(fontSize: 20, color: Colors.white),
)),
],
),
),
);
}),
),
);
}
完结🎉🎉🎉
怕自己忘记代码怎么写的,刚做完功能就写了文档,写了3个小时,头都大了,还是写代码轻松😂😂
有些地方可能讲的不是很清楚,各位可以直接进GitHub仓库查看源码 github.com/Astra1427/x…