【Flutter】学习 stream 的操作,健身app动作演示功能实现

405 阅读8分钟

【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

这里我们注意一下 currentTrainingTaskItemcurrentAction ,将 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();
  }
}
通过currentTrainingTaskItem迭代整个链表,同时将currentTrainingTaskItem放入流中,每个task都有一个delayTime,这用来控制执行下一个任务的间隔时间(同时也是task的执行时间),等待delayTime后将currentTrainingTaskItem设为currentTrainingTaskItem.next,这样就算是完整的流程了。
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();
}
这个方法在销毁页面onDestory()中调用

三、界面实现

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…