Flutter弹幕组件设计

本文介绍https://github.com/flutte-danmaku/flutter_danmaku该项目的设计思路和一些实现细节。

模块

  1. 轨道
  2. 子弹
  3. 控制器
    该项目主要围绕这三块来进行开发。基于这三个模块,解决诸如弹幕的碰撞检测,轨道的动态更新,控制器的渲染等问题。

下文将介绍各个模块的实现细节和思路

子弹

子弹的设计要考虑碰撞检测,宽度对速度的影响,变速需求,个性化子弹的需求,偏移量,静止弹幕以及滚动弹幕的需求。

子弹的基本设计

首先简单介绍子弹模型中重要的成员变量

class FlutterDanmakuBulletModel {
  UniqueKey id; // 通过id查询该子弹
  UniqueKey trackId; // 绑定的轨道id
  UniqueKey prevBulletId; // 轨道中上一个子弹的id
  ...
  double _runDistance = 0; // 已经移动的距离
  double everyFrameRunDistance; // 每帧移动的距离
  ...

  /// 滚动子弹的x轴位置
  double get offsetX => _runDistance - bulletSize.width; 

  /// 子弹最大可跑距离 子弹宽度+墙宽度
  double get maxRunDistance => bulletSize.width + FlutterDanmakuConfig.areaSize.width; 
  
  /// 子弹整体脱离右边墙壁
  bool get allOutRight => _runDistance > bulletSize.width;

  /// 子弹整体离开屏幕
  bool get allOutLeave => _runDistance > maxRunDistance;

  /// 子弹当前执行的距离
  double get runDistance => _runDistance;

  /// 剩余离开的距离
  double get remanderDistance => needRunDistace - runDistance;

  /// 直到消失需要移动的距离
  double get needRunDistace => FlutterDanmakuConfig.areaSize.width + bulletSize.width;

  /// 离开屏幕剩余需要的时间
  double get leaveScreenRemainderTime => remanderDistance / everyFrameRunDistance;

  /// 子弹执行下一帧
  void runNextFrame() {
    _runDistance += everyFrameRunDistance * FlutterDanmakuConfig.bulletRate;
  }
}

从成员变量来看 会发现子弹的距离判断都需要依赖子弹本身的宽度和弹幕墙的宽度,这是由于子弹的出现从头部计算而子弹的隐藏和碰撞从尾部计算。

WechatIMG250

弹幕的数据结构

保存所有弹幕的方式,思考过两种方案。
一种是在轨道上通过数组保存该轨道上所有的子弹Id。使用Map作为索引Key为Id,Value为下标。
一种是以链表的形式链接轨道上的所有子弹。使用Map作为索引Key为Id,Value为Model。

最终选择以链表的形式保存弹幕的方案。
主要出于以下考虑:

  1. 数组删除操作的时间复杂度为O(N)。数组中的每一项的内存地址是连续的,删除数组的其中一项,需要将后续每一项的内存地址减1。而链表的删除操作时间复杂度为O(1)。只需要将上一项指向下一项。

链表 + Map类似LRUCache算法,不过LRUCache算法使用的是双向链表 + HashMap LRUCache使用HashMap实现O(1)的查找,使用双向链表实现O(1)的节点删除和移动。

链表删除节点 [https://leetcode-cn.com/problems/shan-chu-lian-biao-de-jie-dian-lcof/]
LRUCache [https://leetcode-cn.com/problems/lru-cache/]

每个子弹通过prevBulletId以链表的数据结构联接,使用Map作为索引。

class FlutterDanmakuBulletManager {
  Map<UniqueKey, FlutterDanmakuBulletModel> _bullets = {};
  Map<UniqueKey, FlutterDanmakuBulletModel> get bulletsMap => _bullets;
 ...
}

弹幕的追尾

插入弹幕的时候,需要遍历每个轨道是否允许插入新的弹幕,除了考虑该轨道最后一个子弹的一些特征外,还需要判断新插入的轨道是否会追尾。

由于弹幕越长,跑的越快(基本速率 + (宽度 / 倍率))的特性,即便前一个弹幕已经过了一半,还需要考虑新的弹幕是否会速度快到导致追尾。


  // 轨道注入子弹是否会碰撞
  static bool trackInsertBulletHasBump(FlutterDanmakuBulletModel trackLastBullet, Size needInsertBulletSize, {int offsetMS = 0}) {
    // 是否离开了右边的墙壁
    if (!trackLastBullet.allOutRight) return true;
    double willInsertBulletEveryFramerateRunDistance = FlutterDanmakuUtils.getBulletEveryFramerateRunDistance(needInsertBulletSize.width);
    bool hasInsertOffsetSpace = true;
    double willInsertBulletRunDistance = offsetMS == null ? 0 : (offsetMS / FlutterDanmakuConfig.unitTimer) * willInsertBulletEveryFramerateRunDistance;
    if (offsetMS != null) hasInsertOffsetSpace = hasInsertOffsetSpaceComputed(trackLastBullet, willInsertBulletRunDistance);
    if (!hasInsertOffsetSpace) return true;
    // 要注入的节点速度比上一个快
    if (willInsertBulletEveryFramerateRunDistance > trackLastBullet.everyFrameRunDistance) {
      // 是否会追尾
      // 将要注入的弹幕全部离开减去上一个弹幕宽度需要的时间
      double willInsertBulletLeaveScreenRemainderTime = remainderTimeLeaveScreen(willInsertBulletRunDistance, 0, willInsertBulletEveryFramerateRunDistance);
      return trackLastBullet.leaveScreenRemainderTime > willInsertBulletLeaveScreenRemainderTime;
    } else {
      return false;
    }
  }

  // 子弹剩余多少帧离开屏幕
  static double remainderTimeLeaveScreen(double runDistance, double textWidth, double everyFramerateDistance) {
    assert(runDistance >= 0);
    assert(textWidth >= 0);
    assert(everyFramerateDistance > 0);
    double remanderDistance = (FlutterDanmakuConfig.areaSize.width + textWidth) - runDistance;
    return remanderDistance / everyFramerateDistance;
  }

需要做的一些判断

  1. 如果该轨道的最后一个子弹全部离开,那么就允许插入新的子弹
  2. 该轨道上最后一个子弹是否已经离开右侧的墙壁,如果尾部还未离开右侧的墙壁,那么就会追尾。
  3. 如果轨道上最后一个子弹已经在轨道中跑,并且速度慢于新插入的轨道,那么需要考虑轨道最后一个子弹在剩余跑的时间内是否会被新插入的子弹追尾。如果上一个子弹离开屏幕剩余帧数大于新插入子弹离开屏幕剩余帧数,就会追尾。

计算子弹离开剩余帧数 = 离开屏幕剩余距离 - 每帧需要跑的距离。

插入一颗子弹

首先需要计算该弹幕的尺寸,通过获取弹幕的宽度,用来调整弹幕每帧的行进距离,使得弹幕墙整体错落有致。


  // 根据文字长度计算每一帧需要run多少距离
  static double getBulletEveryFramerateRunDistance(double bulletWidth) {
    assert(bulletWidth > 0);
    return FlutterDanmakuConfig.baseRunDistance + (bulletWidth / FlutterDanmakuConfig.everyFramerateRunDistanceScale);
  }

需要查询可用的轨道,如果没有找到可用的轨道,返回一个错误信息,让业务层自行处理

if (track == null)
  return AddBulletResBody(
    AddBulletResCode.noSpace,
  );

如果允许插入,根据弹幕类型分别记录。

轨道

轨道使弹幕不需要计算X轴碰撞。轨道的设计需要考虑弹幕墙的高度,Y轴区间,以及满足展示弹幕区域的业务需求,并且需要提供弹幕链表的头节点以方便遍历操作。

初始化轨道

首先根据文字配置获取文字高度,然后填充满弹幕墙。并且每一条轨道都需要偏移弹幕墙的剩余高度 / 2 使其居中。

  // 补足屏幕内轨道
  void buildTrackFullScreen() {
    Size singleTextSize = FlutterDanmakuUtils.getDanmakuBulletSizeByText('s');
    while (allTrackHeight < (FlutterDanmakuConfig.areaSize.height - singleTextSize.height)) {
      buildTrack(singleTextSize.height);
    }
  }

删除轨道上所有子弹

根据轨道记录的最后一个子弹,通过遍历取上一颗子弹,来删除所有的弹幕。

  // 删除轨道上的所有子弹
  void delBullletsByTrack(FlutterDanmakuTrack track, Map<UniqueKey, FlutterDanmakuBulletModel> bulletMap) {
    if (track.bindFixedBulletId != null) bulletMap.remove(track.bindFixedBulletId);
    UniqueKey prevBulletId = track.lastBulletId;
    while (prevBulletId != null) {
      UniqueKey _prevBulletId = bulletMap[prevBulletId]?.prevBulletId;
      bulletMap.remove(prevBulletId);
      prevBulletId = _prevBulletId;
    }
  }

控制器

弹幕的播放通过控制器控制,控制器由定时器驱动,每隔一段时间,就会遍历所有的子弹,让每个子弹Model的位置数据更新到下一帧的位置数据,然后调用setState()统一渲染。

  void run(Function nextFrame, Function setState) {
    _timer = Timer.periodic(Duration(milliseconds: FlutterDanmakuConfig.unitTimer), (Timer timer) {
      // 暂停不执行
      if (!FlutterDanmakuConfig.pause) {
        // 将所有子弹的位置参数更新为下一帧
        nextFrame();
        // 最后统一渲染
        setState(() {});
      }
    });
  }

  /// 子弹执行下一帧
  void runNextFrame() {
    _runDistance += everyFrameRunDistance * FlutterDanmakuConfig.bulletRate;
  }

定时器并不会到点就执行,而是到点插入事件队列,由于Dart与JS使用相同的事件循环模型,那么定时器会被插入宏任务队列。 需要注意如果前面的执行任务耗费了过多的时间,那么会严重影响下次调用setState的时机导致掉帧。

在弹幕组件中,如何规避掉帧

  1. 由于Dart是单线程语言(同JS),执行依赖于事件循环模型。单线程的特点是只能利用单核CPU进行计算。时间复杂度过高的计算操作会使CPU在60MS内来不及执行到下一个setState操作。所以针对时间复杂度高的计算,需要考虑起一个新的计算线程(Dart Isolate JS WebWorker Nodejs worker_threads)让这个计算操作在其他的CPU内核中执行,以防加入当前的任务队列使任务队列过长导致setState事件延后。
  2. 参考事件委托,防止在每个子任务中调用耗时操作。在这里,将每个弹幕的数据修改后,统一调用setState。与之相同的思路有从替换不同子节点的公共祖先节点。
  3. 尽量避免渲染线程执行过于复杂的计算,弹幕子弹层级过多,或者UI效果过于复杂(高斯模糊 图片 等)。

事件循环 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop
寻找公共父祖先 如果多次修改dom节点会导致浏览器的多次重排,那么比较好的方案是找出两个节点的公共祖先节点,从祖先节点开始替换。https://leetcode-cn.com/problems/er-cha-shu-de-zui-jin-gong-gong-zu-xian-lcof/

简单介绍一下这个项目

https://github.com/flutte-danmaku/flutter_danmaku

基于Flutter的弹幕项目

实现以下功能

Features

  • 色彩弹幕
  • 静止弹幕
  • 滚动弹幕
  • 底部弹幕
  • 可变速
  • 调整大小
  • 配置透明度
  • 调整展示区域
  • 播放 && 暂停
  • 自定义弹幕背景
  • 弹幕点击回调

欢迎点击https://a62527776a.github.io/flutter_danmaku_demo/index.html试用

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,558评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,002评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,024评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,144评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,255评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,295评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,068评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,478评论 1 305
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,789评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,965评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,649评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,267评论 3 318
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,982评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,223评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,800评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,847评论 2 351

推荐阅读更多精彩内容