flutter鸿蒙:实现类似B站或抖音的弹幕功能
1. 前言
需要借助插件实现,目前推荐几款插件都是纯Dart语言开发的,虽然没有单独适配鸿蒙,但是可以在鸿蒙设备上使用,缺点是,会有轻微掉帧的情况。
本人使用设备:华为mate60 Pro
2. 插件仓库链接
- flutter_barrage:
https://github.com/danielwii/flutter_barrage
- flutter_easy_barrage:
https://github.com/Avengong/flutter_easy_barrage
- flutter_barrage_craft:
https://github.com/taxze6/flutter_barrage_craft
- canvas_danmaku:
https://github.com/Predidit/canvas_danmaku
3. 核心代码
- flutter_barrage:
library flutter_barrage;import 'dart:async';
import 'dart:collection';
import 'dart:math';import 'package:flutter/material.dart';
import 'package:quiver/collection.dart';const TAG = 'FlutterBarrage';class BarrageWall extends StatefulWidget {final BarrageWallController controller;/// the bullet widgetfinal Widget child;/// time in seconds of bullet show in screenfinal int speed;/// used to adjust speed for each channelfinal int speedCorrectionInMilliseconds;final double width;final double height;/// will not send bullets to the area is safe from bottom, default is 0/// used to not cover the subtitlesfinal int safeBottomHeight;/// [disable] by default, set to true will overwrite other bulletsfinal bool massiveMode;/// used to make barrage tidyfinal double maxBulletHeight;/// enable debug mode, will display a debug panel with informationfinal bool debug;final bool selfCreatedController;BarrageWall({List<Bullet>? bullets,BarrageWallController? controller,ValueNotifier<BarrageValue>? timelineNotifier,this.speed = 5,this.child = const SizedBox(),required this.width,required this.height,this.massiveMode = false,this.maxBulletHeight = 16,this.debug = false,this.safeBottomHeight = 0,this.speedCorrectionInMilliseconds = 3000,}) : controller = controller ??BarrageWallController.withBarrages(bullets,timelineNotifier: timelineNotifier),selfCreatedController = controller == null {if (controller != null) {this.controller.value = controller.value.size == 0? BarrageWallValue.fromList(bullets ?? []): controller.value;this.controller.timelineNotifier =controller.timelineNotifier ?? timelineNotifier;}}@overrideState<StatefulWidget> createState() => _BarrageState();
}/// It's a class that holds the position of a bullet
class BulletPos {int id;int channel;double position; // from right to leftdouble width;bool released = false;int lifetime;Widget widget;BulletPos({required this.id,required this.channel,required this.position,required this.width,required this.widget}): lifetime = DateTime.now().millisecondsSinceEpoch;updateWith({required double position, double width = 0}) {this.position = position;this.width = width > 0 ? width : this.width;this.lifetime = DateTime.now().millisecondsSinceEpoch;
// debugPrint("[$TAG] update to $this");}bool get hasExtraSpace {return position > width + 8;}@overrideString toString() {return 'BulletPos{id: $id, channel: $channel, position: $position, width: $width, released: $released, widget: $widget}';}
}class _BarrageState extends State<BarrageWall> with TickerProviderStateMixin {late BarrageWallController _controller;Random _random = new Random();// int _processed = 0;// double? _width;// double? _height;double? _lastHeight; // 上一次计算通道个数的的高度记录late Timer _cleaner;double? _maxBulletHeight;int? _totalChannels;int? _channelMask;// Map<dynamic, BulletPos> _lastBullets = {};List<int> _speedCorrectionForChannels = [];int _calcSafeHeight(double height) {if (height.isInfinite) {final toHeight = context.size!.height;debugPrint("[$TAG] height is infinite, set it to $toHeight");return toHeight.toInt();} else {final safeBottomHeight =_controller.safeBottomHeight ?? widget.safeBottomHeight;final toHeight = height - safeBottomHeight;debugPrint('[$TAG] safe bottom height: $safeBottomHeight, set safe height to $toHeight');if (toHeight < 0) {throw Exception('safe bottom height is too large, it should be less than $height');}return toHeight.toInt();}}/// null means no available channels existsint? _nextChannel() {final _randomSeed = _totalChannels! - 1;if (_controller.usedChannel ^ _channelMask! == 0) {return null;}var times = 1;var channel = _randomSeed == 0 ? 0 : _random.nextInt(_randomSeed);var channelCode = 1 << channel;while (_controller.usedChannel & channelCode != 0 &&_controller.usedChannel ^ _channelMask! != 0) {times++;channel = channel >= _totalChannels! ? 0 : channel + 1;channelCode = 1 << channel;/// return random channel if no channels available and massive mode is enabledif (times > _totalChannels!) {if (widget.massiveMode == true) {return _random.nextInt(_randomSeed);}return null;}}// _controller.usedChannel |= (1 << channel);_controller.updateChannel((usedChannel) => usedChannel |= (1 << channel));return channel;}_releaseChannels() {
// final now = DateTime.now().millisecondsSinceEpoch;for (int i = 0; i < _controller.lastBullets.length; i++) {final channel = _controller.lastBullets.keys.elementAt(i);var isNotReleased = !_controller.lastBullets[channel]!.released;var liveTooLong =false; // now - _controller.lastBullets[channel].lifetime > widget.speed * 2 * 1000 + widget.speedCorrectionInMilliseconds;if (liveTooLong ||(isNotReleased && _controller.lastBullets[channel]!.hasExtraSpace)) {_controller.lastBullets[channel]!.released = true;// _controller.usedChannel &= _channelMask! ^ 1 << channel;_controller.updateChannel((usedChannel) => usedChannel &= _channelMask! ^ 1 << channel);}}}void _handleBullets(BuildContext context, {required List<Bullet> bullets,required double width,double? end,}) {// cannot get the width of widget when not rendered, make a twice longer width for nowend ??= width * 2;_releaseChannels();if (widget.debug)debugPrint('[$TAG] handle bullets: ${bullets.length} - ${_controller.usedChannel.toRadixString(2)}');bullets.forEach((Bullet bullet) {AnimationController animationController;final nextChannel = _nextChannel();if (nextChannel != null) {}/// discard bullets do not have available channel and massive mode is not enabled tooif (nextChannel == null) {return;}final showTimeInMilliseconds =widget.speed * 2 * 1000 - _speedCorrectionForChannels[nextChannel];animationController = AnimationController(duration: Duration(milliseconds: showTimeInMilliseconds),vsync: this);Animation<double> animation = new Tween<double>(begin: 0, end: end).animate(animationController..forward());final channelHeightPos = nextChannel * _maxBulletHeight!;/// make bullets not showed up in same timefinal fixedWidth = width + _random.nextInt(20).toDouble();final bulletWidget = AnimatedBuilder(animation: animation,child: bullet.child,builder: (BuildContext context, Widget? child) {if (animation.isCompleted) {_controller.lastBullets[nextChannel]?.updateWith(position: double.infinity);return const SizedBox();}double widgetWidth = 0.0;/// get current widget widthif (child != null) {final renderBox = context.findRenderObject() as RenderBox?;if (renderBox?.hasSize ?? false) {widgetWidth = renderBox!.size.width;/// 通过计算出的 widget width 在判断弹幕完全移出了可视区域if (widgetWidth > 0 &&animation.value > (fixedWidth + widgetWidth)) {_controller.lastBullets[nextChannel]?.updateWith(position: double.infinity);return const SizedBox();}}}// debugPrint(
// '[$TAG] ${_controller.lastBullets[nextChannel]?.id} == ${context.hashCode} $child pos: ${animation.value}');// 【通道不为空】或者【通道的最后元素】之后出现了可以新增的元素if (!_controller.lastBullets.containsKey(nextChannel) ||(_controller.lastBullets.containsKey(nextChannel) &&_controller.lastBullets[nextChannel]!.position >animation.value)) {_controller.lastBullets[nextChannel] = BulletPos(id: context.hashCode,channel: nextChannel,position: animation.value,width: widgetWidth,widget: child!);
// debugPrint("[$TAG] add ${_controller.lastBullets[nextChannel]} - ${context.hashCode}");} else if (_controller.lastBullets[nextChannel]!.id ==context.hashCode) {// 当前元素是最后元素,更新相关信息_controller.lastBullets[nextChannel]?.updateWith(position: animation.value, width: widgetWidth);} // 其他情况直接更新页面元素final widthPos = fixedWidth - animation.value;return Transform.translate(offset: Offset(widthPos, channelHeightPos.toDouble()),child: child,);},);_controller.widgets.putIfAbsent(animationController, () => bulletWidget);});}@overridevoid didUpdateWidget(BarrageWall oldWidget) {super.didUpdateWidget(oldWidget);if (widget.controller != oldWidget.controller) {_controller = widget.controller;}}void handleBullets() {if (_controller.isEnabled && _controller.value.waitingList.isNotEmpty) {final recallNeeded = _lastHeight != widget.height || _channelMask == null;if (_totalChannels == null || recallNeeded) {_lastHeight = widget.height;_maxBulletHeight = widget.maxBulletHeight;_totalChannels = _calcSafeHeight(widget.height) ~/ _maxBulletHeight!;debugPrint("[$TAG] total channels: ${_totalChannels! + 1}");_channelMask = (2 << _totalChannels!) - 1;for (var i = 0; i <= _totalChannels!; i++) {final nextSpeed = widget.speedCorrectionInMilliseconds > 0? _random.nextInt(widget.speedCorrectionInMilliseconds): 0;_speedCorrectionForChannels.add(nextSpeed);}}_handleBullets(context,bullets: _controller.value.waitingList,width: widget.width,);// _processed += _controller.value.waitingList.length;setState(() {});}}@overridevoid initState() {_controller = widget.controller;_controller.initialize();_controller.addListener(handleBullets);_controller.enabledNotifier.addListener(() {setState(() {});});_cleaner = Timer.periodic(Duration(milliseconds: 100), (timer) {_controller.widgets.removeWhere((controller, widget) {if (controller.isCompleted) {controller.dispose();return true;}return false;});});super.initState();}@overridevoid dispose() {debugPrint('[$TAG] dispose');_cleaner.cancel();_controller.clear();_controller.removeListener(handleBullets);if (widget.selfCreatedController) {_controller.dispose();}super.dispose();}@overrideWidget build(BuildContext context) {return Stack(fit: StackFit.expand, children: <Widget>[if (widget.debug)Container(color: Colors.lightBlueAccent.withOpacity(0.7),child: Column(crossAxisAlignment: CrossAxisAlignment.start,mainAxisAlignment: MainAxisAlignment.end,children: <Widget>[Text('BarrageWallValue: ${_controller.value}'),Text('TimelineNotifier: ${_controller.timelineNotifier?.value}'),Text('Timeline: ${_controller.timeline}'),Text('Bullets: ${_controller.widgets.length}'),Text('UsedChannels: ${_controller.usedChannel.toRadixString(2)}'),Text('LastBullets[0]: ${_controller.lastBullets[0]}'),])),widget.child,if (_controller.isEnabled)Stack(fit: StackFit.loose,children: <Widget>[..._controller.widgets.values]// ..addAll(_widgets.values ?? const SizedBox()),),]);}
}typedef int KeyCalculator<T>(T t);class HashList<T> {/// key is the showTime in minutesMap<int, TreeSet<T>> _map = new HashMap();final Comparator<T>? comparator;final KeyCalculator<T> keyCalculator;HashList({required this.keyCalculator, this.comparator});void appendByMinutes(List<T> values) {values.forEach((value) {int key = keyCalculator(value);if (_map.containsKey(key)) {_map[key]!.add(value);} else {_map.putIfAbsent(key,() => TreeSet<T>(comparator: comparator ?? (dynamic a, b) => a.compareTo(b))..add(value));}});}@overrideString toString() {return 'HashList{$_map}';}
}class BarrageValue {final int timeline;final bool isPlaying;BarrageValue({this.timeline = -1, this.isPlaying = false});BarrageValue copyWith({int? timeline, bool? isPlaying}) => BarrageValue(timeline: timeline ?? this.timeline,isPlaying: isPlaying ?? this.isPlaying);@overrideString toString() {return 'BarrageValue{timeline: $timeline, isPlaying: $isPlaying}';}
}class BarrageWallValue {final int showedTimeBefore;final int size;final int processedSize;final List<Bullet> waitingList;final HashList<Bullet> bullets;BarrageWallValue.fromList(List<Bullet> bullets,{this.showedTimeBefore = 0, this.waitingList = const []}): bullets = HashList<Bullet>(keyCalculator: (t) => Duration(milliseconds: t.showTime).inMinutes)..appendByMinutes(bullets),size = bullets.length,processedSize = 0;BarrageWallValue({required this.bullets,this.showedTimeBefore = 0,this.waitingList = const [],this.size = 0,this.processedSize = 0,});BarrageWallValue copyWith({// int lastProcessedTime,required int processedSize,int? showedTimeBefore,List<Bullet>? waitingList,}) =>BarrageWallValue(bullets: bullets,showedTimeBefore: showedTimeBefore ?? this.showedTimeBefore,waitingList: waitingList ?? this.waitingList,size: this.size,processedSize: this.processedSize + processedSize,);@overrideString toString() {return 'BarrageWallValue{showedTimeBefore: $showedTimeBefore, size: $size, processed: $processedSize, waitings: ${waitingList.length}}';}
}class BarrageWallController extends ValueNotifier<BarrageWallValue> {Map<AnimationController, Widget> _widgets = new LinkedHashMap();Map<dynamic, BulletPos> _lastBullets = {};int _usedChannel = 0;int timeline = 0;ValueNotifier<bool> enabledNotifier = ValueNotifier(true);bool _isDisposed = false;ValueNotifier<BarrageValue>? timelineNotifier;int? safeBottomHeight;Timer? _timer;bool get isEnabled => enabledNotifier.value;Map<AnimationController, Widget> get widgets => _widgets;Map<dynamic, BulletPos> get lastBullets => _lastBullets;int get usedChannel => _usedChannel;BarrageWallController({List<Bullet>? bullets, this.timelineNotifier}): super(BarrageWallValue.fromList(bullets ?? const []));BarrageWallController.withBarrages(List<Bullet>? bullets,{this.timelineNotifier}): super(BarrageWallValue.fromList(bullets ?? const []));Future<void> initialize() async {final Completer<void> initializingCompleter = Completer<void>();if (timelineNotifier == null) {_timer = Timer.periodic(const Duration(milliseconds: 100),(Timer timer) async {if (_isDisposed) {timer.cancel();return;}if (value.size == value.processedSize) {/*timer.cancel();*/return;}timeline += 100;tryFire();});} else {timelineNotifier!.addListener(_handleTimelineNotifier);}initializingCompleter.complete();return initializingCompleter.future;}/// reset the controller to new time statevoid reset(int showedTimeBefore) {value = value.copyWith(showedTimeBefore: showedTimeBefore, waitingList: [], processedSize: 0);}void updateChannel(Function(int usedChannel) onUpdate) {_usedChannel = onUpdate(_usedChannel);}/// clear all firing bulletsvoid clear() {/// reset all widgets animation and clear the listwidgets.forEach((controller, widget) => controller.dispose());widgets.clear();// release channels_usedChannel = 0;}void _handleTimelineNotifier() {final offset = (timeline - timelineNotifier!.value.timeline);final ifNeedReset = offset.abs() > 1000;if (ifNeedReset) {debugPrint("[$TAG] offset: $offset call reset to $timeline...");reset(timelineNotifier!.value.timeline);}if (timelineNotifier != null) timeline = timelineNotifier!.value.timeline;tryFire();}tryFire({List<Bullet> bullets = const []}) {final key = Duration(milliseconds: timeline).inMinutes;final exists = value.bullets._map.containsKey(key);if (exists || bullets.isNotEmpty) {List<Bullet> toBePrecessed = value.bullets._map[key]?.where((barrage) =>barrage.showTime > value.showedTimeBefore &&barrage.showTime <= timeline).toList() ??[];if (toBePrecessed.isNotEmpty || bullets.isNotEmpty) {value = value.copyWith(showedTimeBefore: timeline,waitingList: toBePrecessed..addAll(bullets),processedSize: toBePrecessed.length);}}}disable() {debugPrint("[$TAG] disable barrage ... current: $enabledNotifier");enabledNotifier.value = false;}enable() {debugPrint("[$TAG] enable barrage ... current: $enabledNotifier");enabledNotifier.value = true;}send(List<Bullet> bullets) {tryFire(bullets: bullets);}@overrideFuture<void> dispose() async {if (!_isDisposed) {_timer?.cancel();}_isDisposed = true;timelineNotifier?.dispose();enabledNotifier.dispose();super.dispose();}
}class Bullet implements Comparable<Bullet> {final Widget child;/// in millisecondsfinal int showTime;const Bullet({required this.child, this.showTime = 0});@overrideString toString() {return 'Bullet{child: $child, showTime: $showTime}';}@overrideint compareTo(Bullet other) {return showTime.compareTo(other.showTime);}
}
这段代码实现了一个 Flutter 弹幕组件,核心思路是通过时间线管理弹幕触发、通道划分避免重叠、动画控制移动轨迹,最终实现从右向左流动的弹幕效果。以下是核心逻辑拆解:
1. 核心组件与职责划分
BarrageWall:弹幕容器组件(StatefulWidget),负责渲染弹幕和管理视图状态,依赖控制器处理业务逻辑。
BarrageWallController:控制器,核心角色是管理弹幕数据(待显示、已显示)、时间线(触发时机)和资源释放。
Bullet:弹幕实体,包含要显示的 Widget 和触发时间(showTime,毫秒级)。
BulletPos:弹幕位置跟踪器,记录弹幕所在通道、当前位置、宽度等,用于判断是否释放通道。
2. 弹幕生命周期管理(核心流程)
(1)初始化与时间线驱动
控制器初始化时启动定时器(或监听外部时间通知),定期更新时间线(timeline,毫秒级)。
时间线每更新一次,触发tryFire方法,从存储的弹幕列表中筛选出 “当前时间应显示” 的弹幕(showTime在已显示时间和当前时间之间),放入waitingList待处理。
(2)通道划分与分配(避免重叠)
将弹幕容器垂直划分为多个 “通道”:通道数量 = 安全高度(总高度 - 底部安全区域)÷ 最大弹幕高度(maxBulletHeight)。
用位掩码(usedChannel)记录已占用的通道(例如,第 0 通道占用则usedChannel=0b0001)。
_nextChannel方法通过位运算判断空闲通道,优先分配空闲通道;若通道满且开启massiveMode,则随机分配(允许重叠)。
(3)动画控制与位置更新
为每个待显示弹幕创建AnimationController,动画时长由基础速度(speed)和通道修正值(speedCorrectionInMilliseconds)决定,实现不同通道速度差异。
动画值从 0 到容器宽度的 2 倍(Tween(begin:0, end:width*2)),通过AnimatedBuilder实时更新弹幕位置:
水平位置:width - 动画值(从右侧进入,向左移动)。
垂直位置:通道索引 × 最大弹幕高度(固定在所属通道)。
通过BulletPos跟踪弹幕实时位置和宽度,当弹幕完全移出屏幕(position > 宽度 + 自身宽度),标记为 “已释放” 并更新位掩码释放通道。
(4)资源清理
定时器(_cleaner)每 100ms 检查一次已完成的动画控制器,销毁并从列表中移除,避免内存泄漏。
当容器销毁或控制器禁用时,清理所有动画资源并重置通道状态。
3. 关键特性支持
时间线同步:支持外部时间通知(timelineNotifier),可与视频等场景同步弹幕显示。
安全区域:通过safeBottomHeight预留底部区域(如字幕区),弹幕不进入该区域。
调试模式:开启debug后显示调试面板,包含弹幕数量、通道占用等信息。
海量模式:massiveMode开启时,通道满后仍强制分配,允许弹幕重叠以保证高并发显示。
总结
核心逻辑可概括为:“时间线触发弹幕 → 通道分配避免重叠 → 动画控制移动轨迹 → 实时跟踪并释放资源”,通过控制器与视图分离、位运算高效管理通道、动画驱动位置更新,实现了流畅且可配置的弹幕效果。
- flutter_easy_barrage:
import 'dart:async';
import 'dart:collection';
import 'dart:math';
import 'package:flutter/material.dart';class EasyBarrage extends StatefulWidget {final double width;final double height;final int rowNum;/// 行轨道之间的行间距高度final double rowSpaceHeight;///一行中,每个item的水平间距宽度final double itemSpaceWidth;final Duration duration;///是否随机final bool randomItemSpace;final EasyBarrageController controller;///弹幕从某个位置开始出现,默认是0final double originStart;/// 轨道下标:对应轨道弹幕延迟出现的时间final Map<int, Duration>? channelDelayMap;/// 默认从右到左final TransitionDirection direction;EasyBarrage({Key? key,required this.width,required this.height,required this.controller,this.itemSpaceWidth = 45,this.rowNum = 3,this.channelDelayMap,this.originStart = 0,this.direction = TransitionDirection.rtl,this.randomItemSpace = false,this.duration = const Duration(seconds: 5),this.rowSpaceHeight = 10,}) : super(key: key);@overrideState<StatefulWidget> createState() {return EasyBarrageState();}
}class EasyBarrageState extends State<EasyBarrage> {List<BarrageLineController> controllers = [];late EasyBarrageController _controller;final Random _random = Random();Timer? _timeline;@overridevoid initState() {_controller = widget.controller;_timeline = Timer.periodic(const Duration(milliseconds: 50), (timer) {for (var element in controllers) {element.tick();}});_controller.addListener(handleBarrages);var rows = widget.rowNum;for (int i = 0; i < rows; i++) {BarrageLineController barrageController = BarrageLineController();controllers.add(barrageController);}_controller.speedNotify.addListener(() {controllers.forEach((element) {element.updateSpeed(_controller.speedNotify.value);});});super.initState();}@overridevoid dispose() {releaseTimeLine();controllers.clear();super.dispose();}void handleBarrages() {double totalSpaceWidth = (widget.rowNum - 1) * widget.itemSpaceWidth;_controller.totalMapBarrageItems.forEach((key, value) {BarrageLineController ctrl = controllers[key];dispatch(ctrl, value, key, _controller.slideWidth + totalSpaceWidth);});if (_controller.totalBarrageItems.isNotEmpty) {for (int i = 0; i < controllers.length; i++) {List<BarrageItem> templist = [];templist.addAll(_controller.totalBarrageItems);dispatch(controllers[i], templist, i, _controller.slideWidth + totalSpaceWidth);}}}@overrideWidget build(BuildContext context) {return Container(width: widget.width,height: widget.height,child: Column(children: [...barrageLines()],),);}List<Widget> barrageLines() {List<Widget> list = [];int rows = widget.rowNum;double height = ((widget.height - (rows - 1) * (widget.rowSpaceHeight)) / rows);for (int i = 0; i < rows; i++) {list.add(BarrageLine(direction: widget.direction,controller: controllers[i],fixedWidth: widget.width,itemSpaceWidth: widget.itemSpaceWidth,originStart: widget.originStart,height: height,duration: widget.duration,));if (i != rows - 1) {list.add(SizedBox(height: widget.rowSpaceHeight,));}}return list;}void _randTrigger(BarrageLineController ctrl, List<BarrageItem> value, double slideWidth) {if (_random.nextBool()) {Future.delayed(Duration(milliseconds: _random.nextInt(800)), () {ctrl.trigger(value, slideWidth);});} else {ctrl.trigger(value, slideWidth);}}void dispatch(BarrageLineController ctrl, List<BarrageItem> value, int channelIndex, double slideWidth) {if (widget.channelDelayMap != null) {Duration? duration = widget.channelDelayMap![channelIndex];if (duration != null) {Future.delayed(duration, () {ctrl.trigger(value, slideWidth);});} else {_randTrigger(ctrl, value, slideWidth);}} else {_randTrigger(ctrl, value, slideWidth);}}void releaseTimeLine() {_timeline?.cancel();_timeline = null;}
}class EasyBarrageController extends ValueNotifier<BarrageItemValue> {List<BarrageItem> totalBarrageItems = [];HashMap<int, List<BarrageItem>> totalMapBarrageItems = HashMap<int, List<BarrageItem>>();ValueNotifier<int> speedNotify=ValueNotifier<int>(0);double slideWidth = 0;EasyBarrageController() : super(BarrageItemValue());void sendBarrage(List<BarrageItem> items) {clearCache();totalBarrageItems.addAll(items);double maxW = 0;double totalW = 0;for (var element in totalBarrageItems) {maxW = max(maxW, element.itemWidth);totalW += element.itemWidth;}slideWidth = totalW;value = BarrageItemValue(widgets: totalBarrageItems, slideWidth: slideWidth);}void sendChannelMapBarrage(HashMap<int, List<BarrageItem>>? channelMapItems) {if (channelMapItems != null) {clearCache();totalMapBarrageItems.addAll(channelMapItems);double totalWidth = 0;double originmaxW = 0;totalMapBarrageItems.forEach((key, value) {double totalW = 0;for (var element in value) {originmaxW = max(originmaxW, element.itemWidth);totalW += element.itemWidth;}totalWidth = max(totalWidth, totalW);});slideWidth = totalWidth;value = BarrageItemValue(mapItems: totalMapBarrageItems, slideWidth: slideWidth);}}void stop() {///todo}void clearCache() {totalMapBarrageItems.clear();totalBarrageItems.clear();}void updateSpeed(int milliseconds) {speedNotify.value=milliseconds;}}typedef HandleComplete = void Function();class BarrageLine extends StatefulWidget {const BarrageLine({required this.controller,Key? key,// this.bgchild,this.duration = const Duration(seconds: 5),this.onHandleComplete,this.itemSpaceWidth = 45,this.randomItemSpace = false,this.originStart = 0,required this.fixedWidth,required this.height,this.direction = TransitionDirection.rtl}): super(key: key);final double height;final bool randomItemSpace;final double itemSpaceWidth;/// 平移时间(秒)///final Duration duration;final double originStart;///弹幕展示的宽度final double fixedWidth;final BarrageLineController controller;////// 平移方向final TransitionDirection direction;final HandleComplete? onHandleComplete;getComplete() {}@overrideState<StatefulWidget> createState() => _BarrageLineState();
}class _BarrageLineState extends State<BarrageLine> with TickerProviderStateMixin {// double _width = 0;// double _height = 0;late BarrageLineController controller;final Random _random = Random();bool hasCalled = false;@overridevoid initState() {controller = widget.controller;controller.addListener(handleBarrages);controller.tickNotifier.addListener(() {handleWaitingBarrages();});super.initState();}@overridevoid dispose() {controller.destroy();controller.removeListener(handleBarrages);super.dispose();}void handleBarrages() {}void handleWaitingBarrages() {double originStart = widget.originStart;if (controller.hasNoItem()) {if (!hasCalled) {widget.onHandleComplete?.call();controller._tickCont=1;hasCalled = true;}return;}hasCalled = false;if (!controller.hasExtraSpace(widget.randomItemSpace ? (_random.nextInt((widget.itemSpaceWidth + originStart).toInt())).toDouble() : (widget.itemSpaceWidth + originStart),widget.direction)) {return;}var element = controller.next();// double childWidth = controller.maxWidth;controller.lastBarrage(element.id, widget.fixedWidth);var duration=widget.duration;int? milliseconds=controller.milliseconds;if(milliseconds!=null){duration=Duration(milliseconds: milliseconds);}Animation<double> animation;AnimationController animationController = AnimationController(duration: duration, vsync: this)..addStatusListener((status) {if (status == AnimationStatus.completed) {controller.barrageItems.removeWhere((element2) => element2.id == element.id);}});var begin = originStart;var end = widget.fixedWidth * 2; // 暂时设置为展示宽度的2倍,理论上应该是 fixedWidth+widget本身的长度。这样可以保证速度一致。// var end = widget.fixedWidth + childWidth + originStart; // 精准!但是有个问题,如果每次的弹幕宽度不一致,会导致速度不一样animation = Tween(begin: begin, end: end).animate(animationController..forward());var widgetBarrage = AnimatedBuilder(animation: animation,child: element.item,builder: (BuildContext context, Widget? child) {if (animation.isCompleted) {controller.updateLastItemPosition(BarrageItemPosition(animationValue: double.infinity, id: element.id));return const SizedBox();}double widgetWidth = 0.0;if (child != null) {RenderObject? renderBox = context.findRenderObject();if (renderBox != null) {var rb = renderBox as RenderBox;if (rb.hasSize == true) {widgetWidth = renderBox.size.width;if (widgetWidth > 0 && animation.value >= (widget.fixedWidth + widgetWidth - 2)) {controller.updateLastItemPosition(BarrageItemPosition(id: element.id, animationValue: double.infinity));return const SizedBox();}}}}var widthPos = widget.fixedWidth - animation.value;if (widget.direction == TransitionDirection.rtl) {widthPos = widget.fixedWidth - animation.value;} else if (widget.direction == TransitionDirection.ltr) {widthPos = animation.value - element.itemWidth;}controller.updateLastItemPosition(BarrageItemPosition(animationValue: animation.value, id: element.id, widgetWidth: widgetWidth));const heightPos = .0;return Transform.translate(offset: Offset(widthPos, heightPos),child: child,);},);controller.widgets.putIfAbsent(animationController, () => widgetBarrage);if(mounted){setState(() {});}}@overrideWidget build(BuildContext context) {return Container(alignment: Alignment.center,height: widget.height,// color: Colors.greenAccent,child: LayoutBuilder(builder: (_, snapshot) {// _width = widget.fixedWidth ?? snapshot.maxWidth;// _height = widget.height ?? snapshot.maxHeight;return Stack(fit: StackFit.expand,// alignment: Alignment.centerLeft,children: <Widget>[// widget.child,Stack(fit: StackFit.loose, alignment: Alignment.centerLeft, children: <Widget>[...controller.widgets.values]),]);}),);}
}class BarrageLineController extends ValueNotifier<BarrageItemValue> {List<BarrageItem> barrageItems = [];double maxWidth = 0;Map<AnimationController, Widget> get widgets => _widgets;BarrageItemPosition? _itemPosition;final Map<AnimationController, Widget> _widgets = {};BarrageLineController() : super(BarrageItemValue());ValueNotifier<int> get tickNotifier => _tickNotifier;final ValueNotifier<int> _tickNotifier = ValueNotifier(0);int? milliseconds;int _tickCont = 1;void trigger(List<BarrageItem> items, double localMaxWidth) {barrageItems.addAll(items);maxWidth = localMaxWidth;value = BarrageItemValue(widgets: barrageItems);}void updateLastItemPosition(BarrageItemPosition itemPosition) {if (_itemPosition?.id == itemPosition.id) {_itemPosition?.animationValue = itemPosition.animationValue;_itemPosition?.widgetWidth = itemPosition.widgetWidth;}}bool hasExtraSpace(double itemSpaceWidth, TransitionDirection direction) {return _itemPosition == null || ((_itemPosition!.animationValue) > (_itemPosition!.widgetWidth + itemSpaceWidth));}void destroy() {dispose();_tickCont = 0;widgets.forEach((key, value) {key.dispose();});widgets.clear();barrageItems.clear();tickNotifier.dispose();}void lastBarrage(String itemId, double fixedWidth) {_itemPosition ??= BarrageItemPosition(id: itemId);_itemPosition!.id = itemId;_itemPosition!.fixedWidth = fixedWidth;}void tick() {widgets.removeWhere((controller, widget) {if (controller.isCompleted) {controller.dispose();return true;}return false;});tickNotifier.value = _tickCont++;}bool hasNoItem() {return barrageItems.isEmpty;}BarrageItem next() {return barrageItems.removeAt(0);}void updateSpeed(int milliseconds) {this.milliseconds=milliseconds;}}class BarrageItemPosition {double animationValue = 0, fixedWidth = 0, widgetWidth = 0;String id;BarrageItemPosition({this.animationValue = 0, this.fixedWidth = 0, this.widgetWidth = 0, required this.id});
}class BarrageItemValue {List<BarrageItem>? widgets;HashMap<int, List<BarrageItem>>? mapItems;double slideWidth; //用来计算行程BarrageItemValue({this.widgets, this.mapItems, this.slideWidth = 0});
}class BarrageItem {Widget item;double itemWidth;String id = "";final Random _random = Random();BarrageItem({required this.item, required this.itemWidth}) {id = "${DateTime.now().toIso8601String()}:${_random.nextInt(1000)}";}
}enum TransitionDirection {////// 从左到右///ltr,////// 从右到左///rtl,////// 从上到下///// ttb, todo////// 从下到上///// btt todo
}
这段代码实现了一个更轻量、可配置的多轨道弹幕组件(EasyBarrage),核心思路是通过垂直划分固定轨道、轨道内弹幕队列管理、动画控制移动方向与速度,实现可定制的弹幕流效果。以下是核心逻辑拆解:
1. 核心组件与职责划分
EasyBarrage:弹幕容器(StatefulWidget),负责整体布局(划分多行轨道),协调轨道控制器与数据更新。
EasyBarrageController:全局控制器,管理待发送的弹幕数据(支持普通列表和按轨道划分的映射),提供发送、清空、速度控制等接口。
BarrageLine:单轨道组件(每行弹幕),负责渲染当前轨道的弹幕,通过动画控制弹幕移动。
BarrageLineController:单轨道控制器,管理该轨道的弹幕队列、动画资源和位置跟踪,确保轨道内弹幕不重叠。
BarrageItem:弹幕实体,包含要显示的 Widget、宽度(提前指定)和唯一 ID(用于跟踪)。
2. 弹幕生命周期管理(核心流程)
(1)初始化与轨道划分
容器初始化时,根据rowNum创建对应数量的BarrageLineController(每个对应一行轨道),并启动定时器(每 50ms 触发一次tick)。
轨道高度计算:总高度减去轨道间距(rowSpaceHeight)后,平均分配给每行(height = (总高度 - (行数-1)*间距) / 行数)。
(2)弹幕发送与分配
发送方式:支持两种发送模式:
普通模式:通过sendBarrage发送弹幕列表,由容器平均分配到所有轨道。
轨道指定模式:通过sendChannelMapBarrage发送按轨道索引划分的弹幕映射(HashMap<int, List<BarrageItem>>),精准分配到指定轨道。
分配逻辑:发送时计算弹幕总宽度(用于动画行程),并通知容器处理。容器通过dispatch方法将弹幕分发给对应轨道的控制器,支持:
轨道延迟:通过channelDelayMap为指定轨道设置弹幕延迟出现时间。
随机延迟:无轨道延迟时,随机添加 0-800ms 延迟,避免所有轨道弹幕同步出现。
(3)轨道内弹幕调度(核心逻辑)
队列管理:每个轨道的BarrageLineController维护一个弹幕队列(barrageItems),通过定时器的tick触发调度。
空间检查:调度时先判断轨道内是否有足够空间(基于上一个弹幕的位置和间距):
间距规则:通过itemSpaceWidth配置水平间距,支持随机间距(randomItemSpace)。
空间判断:hasExtraSpace方法检查上一个弹幕的位置是否超过 “自身宽度 + 间距”,确保不重叠。
动画创建:若有空间,从队列取出下一个弹幕,创建AnimationController:
方向控制:默认从右到左(rtl),也支持从左到右(ltr),通过Tween控制动画值范围(起始位置originStart到结束位置fixedWidth*2)。
速度控制:基础时长由duration配置,支持通过updateSpeed动态修改动画时长(改变速度)。
(4)位置更新与资源清理
实时位置跟踪:通过BarrageItemPosition记录弹幕的动画值、宽度等,AnimatedBuilder实时更新位置:
从右到左:widthPos = 轨道宽度 - 动画值(从右侧进入,向左移动)。
从左到右:widthPos = 动画值 - 弹幕宽度(从左侧进入,向右移动)。
资源清理:当弹幕完全移出轨道(动画完成或位置超出轨道宽度),销毁动画控制器并从列表中移除,避免内存泄漏。
3. 关键特性支持
多轨道隔离:垂直划分为rowNum行轨道,轨道间通过rowSpaceHeight分隔,避免垂直方向重叠。
灵活方向:支持从右到左(默认)和从左到右两种移动方向(TransitionDirection)。
间距控制:可配置固定水平间距,或开启随机间距使弹幕分布更自然。
动态速度:通过控制器的updateSpeed实时调整动画时长,改变弹幕移动速度。
精准分配:支持按轨道指定弹幕(sendChannelMapBarrage),满足特定轨道显示特定内容的需求。
总结
核心逻辑可概括为:“容器划分多轨道 → 控制器分配弹幕到轨道 → 轨道内按队列调度(检查空间) → 动画驱动弹幕按方向 / 速度移动 → 实时跟踪并清理资源”。通过轨道隔离、队列管理和灵活配置,实现了轻量且可定制的弹幕效果,适合需要简单集成多轨道弹幕的场景。
- flutter_barrage_craft:
import 'package:flutter/material.dart';
import 'package:flutter_barrage_craft/src/barrage_controller.dart';
import 'package:flutter_barrage_craft/src/config/barrage_config.dart';
import 'package:flutter_barrage_craft/src/model/barrage_model.dart';class BarrageView extends StatefulWidget {const BarrageView({Key? key, required this.controller}) : super(key: key);final BarrageController controller;@overrideState<BarrageView> createState() => _BarrageViewState();
}class _BarrageViewState extends State<BarrageView> {@overridevoid initState() {super.initState();widget.controller.setState = setState;}@overridevoid dispose() {super.dispose();widget.controller.dispose();}Widget buildBarrage(BuildContext context, BarrageModel barrageModel) {return Positioned(right: barrageModel.offsetX,top: barrageModel.offsetY,child: GestureDetector(onTap: () => BarrageConfig.barrageTapCallBack(barrageModel),onDoubleTap: () => BarrageConfig.barrageDoubleTapCallBack(barrageModel),child: barrageModel.barrageWidget,),);}List<Widget> buildAllBarrage(BuildContext context) {return List.generate(widget.controller.barrages.length,(index) => buildBarrage(context,widget.controller.barrages[index],),);}@overrideWidget build(BuildContext context) {return Stack(children: [...buildAllBarrage(context)],);}
}
- canvas_danmaku:
import 'package:canvas_danmaku/utils/utils.dart';
import 'package:flutter/material.dart';
import 'models/danmaku_item.dart';
import 'scroll_danmaku_painter.dart';
import 'static_danmaku_painter.dart';
import 'danmaku_controller.dart';
import 'dart:ui' as ui;
import 'models/danmaku_option.dart';
import '/models/danmaku_content_item.dart';
import 'dart:math';class DanmakuScreen extends StatefulWidget {// 创建Screen后返回控制器final Function(DanmakuController) createdController;final DanmakuOption option;const DanmakuScreen({required this.createdController,required this.option,super.key,});@overrideState<DanmakuScreen> createState() => _DanmakuScreenState();
}class _DanmakuScreenState extends State<DanmakuScreen>with TickerProviderStateMixin, WidgetsBindingObserver {/// 视图宽度double _viewWidth = 0;/// 弹幕控制器late DanmakuController _controller;/// 弹幕动画控制器late AnimationController _animationController;/// 静态弹幕动画控制器late AnimationController _staticAnimationController;/// 弹幕配置DanmakuOption _option = DanmakuOption();/// 滚动弹幕final List<DanmakuItem> _scrollDanmakuItems = [];/// 顶部弹幕final List<DanmakuItem> _topDanmakuItems = [];/// 底部弹幕final List<DanmakuItem> _bottomDanmakuItems = [];/// 弹幕高度late double _danmakuHeight;/// 弹幕轨道数late int _trackCount;/// 弹幕轨道位置final List<double> _trackYPositions = [];/// 内部计时器late int _tick;/// 运行状态bool _running = true;/// 因进入后台而暂停bool _pauseInBackground = false;@overridevoid initState() {super.initState();// 计时器初始化_tick = 0;_startTick();_option = widget.option;_controller = DanmakuController(onAddDanmaku: addDanmaku,onUpdateOption: updateOption,onPause: pause,onResume: resume,onClear: clearDanmakus,);_controller.option = _option;widget.createdController.call(_controller,);_animationController = AnimationController(vsync: this,duration: Duration(seconds: _option.duration),)..repeat();_staticAnimationController = AnimationController(vsync: this,duration: Duration(seconds: _option.duration),);WidgetsBinding.instance.addObserver(this);}/// 处理 Android/iOS 应用后台或熄屏导致的动画问题@overridevoid didChangeAppLifecycleState(AppLifecycleState state) {if ([AppLifecycleState.paused,AppLifecycleState.detached,].contains(state) &&_pauseInBackground &&_running) {_pauseInBackground = true;pause();} else if (_pauseInBackground) {_pauseInBackground = false;resume();}super.didChangeAppLifecycleState(state);}@overridevoid dispose() {_running = false;WidgetsBinding.instance.removeObserver(this);_animationController.dispose();_staticAnimationController.dispose();super.dispose();}/// 添加弹幕void addDanmaku(DanmakuContentItem content) {if (!_running || !mounted) {return;}// 在这里提前创建 Paragraph 缓存防止卡顿final textPainter = TextPainter(text: TextSpan(text: content.text,style: TextStyle(fontSize: _option.fontSize,fontWeight: FontWeight.values[_option.fontWeight])),textDirection: TextDirection.ltr,)..layout();final danmakuWidth = textPainter.width;final danmakuHeight = textPainter.height;final ui.Paragraph paragraph = Utils.generateParagraph(content, danmakuWidth, _option.fontSize, _option.fontWeight, _option.opacity);ui.Paragraph? strokeParagraph;if (_option.strokeWidth > 0) {strokeParagraph = Utils.generateStrokeParagraph(content, danmakuWidth,_option.fontSize, _option.fontWeight, _option.strokeWidth, _option.opacity);}int idx = 1;for (double yPosition in _trackYPositions) {if (content.type == DanmakuItemType.scroll && !_option.hideScroll) {bool scrollCanAddToTrack =_scrollCanAddToTrack(yPosition, danmakuWidth);if (scrollCanAddToTrack) {_scrollDanmakuItems.add(DanmakuItem(yPosition: yPosition,xPosition: _viewWidth,width: danmakuWidth,height: danmakuHeight,creationTime: _tick,content: content,paragraph: paragraph,strokeParagraph: strokeParagraph));break;}/// 无法填充自己发送的弹幕时强制添加if (content.selfSend && idx == _trackCount) {_scrollDanmakuItems.add(DanmakuItem(yPosition: _trackYPositions[0],xPosition: _viewWidth,width: danmakuWidth,height: danmakuHeight,creationTime: _tick,content: content,paragraph: paragraph,strokeParagraph: strokeParagraph));break;}/// 海量弹幕启用时进行随机添加if (_option.massiveMode && idx == _trackCount) {final random = Random();var randomYPosition =_trackYPositions[random.nextInt(_trackYPositions.length)];_scrollDanmakuItems.add(DanmakuItem(yPosition: randomYPosition,xPosition: _viewWidth,width: danmakuWidth,height: danmakuHeight,creationTime: _tick,content: content,paragraph: paragraph,strokeParagraph: strokeParagraph));break;}}if (content.type == DanmakuItemType.top && !_option.hideTop) {bool topCanAddToTrack = _topCanAddToTrack(yPosition);if (topCanAddToTrack) {_topDanmakuItems.add(DanmakuItem(yPosition: yPosition,xPosition: _viewWidth,width: danmakuWidth,height: danmakuHeight,creationTime: _tick,content: content,paragraph: paragraph,strokeParagraph: strokeParagraph));break;}}if (content.type == DanmakuItemType.bottom && !_option.hideBottom) {bool bottomCanAddToTrack = _bottomCanAddToTrack(yPosition);if (bottomCanAddToTrack) {_bottomDanmakuItems.add(DanmakuItem(yPosition: yPosition,xPosition: _viewWidth,width: danmakuWidth,height: danmakuHeight,creationTime: _tick,content: content,paragraph: paragraph,strokeParagraph: strokeParagraph));break;}}idx++;}if ((_scrollDanmakuItems.isNotEmpty ||_topDanmakuItems.isNotEmpty ||_bottomDanmakuItems.isNotEmpty) &&!_animationController.isAnimating) {_animationController.repeat();}/// 重绘静态弹幕setState(() {_staticAnimationController.value = 0;});}/// 暂停void pause() {if (!mounted) return;if (_running) {setState(() {_running = false;});if (_animationController.isAnimating) {_animationController.stop();}}}/// 恢复void resume() {if (!mounted) return;if (!_running) {setState(() {_running = true;});if (!_animationController.isAnimating) {_animationController.repeat();// 重启计时器_startTick();}}}/// 更新弹幕设置void updateOption(DanmakuOption option) {bool needRestart = false;bool needClearParagraph = false;if (_animationController.isAnimating) {_animationController.stop();needRestart = true;}if (option.fontSize != _option.fontSize) {needClearParagraph = true;}/// 需要隐藏弹幕时清理已有弹幕if (option.hideScroll && !_option.hideScroll) {_scrollDanmakuItems.clear();}if (option.hideTop && !_option.hideTop) {_topDanmakuItems.clear();}if (option.hideBottom && !_option.hideBottom) {_bottomDanmakuItems.clear();}_option = option;_controller.option = _option;/// 清理已经存在的 Paragraph 缓存if (needClearParagraph) {for (DanmakuItem item in _scrollDanmakuItems) {if (item.paragraph != null) {item.paragraph = null;}if (item.strokeParagraph != null) {item.strokeParagraph = null;}}for (DanmakuItem item in _topDanmakuItems) {if (item.paragraph != null) {item.paragraph = null;}if (item.strokeParagraph != null) {item.strokeParagraph = null;}}for (DanmakuItem item in _bottomDanmakuItems) {if (item.paragraph != null) {item.paragraph = null;}if (item.strokeParagraph != null) {item.strokeParagraph = null;}}}if (needRestart) {_animationController.repeat();}setState(() {});}/// 清空弹幕void clearDanmakus() {if (!mounted) return;setState(() {_scrollDanmakuItems.clear();_topDanmakuItems.clear();_bottomDanmakuItems.clear();});_animationController.stop();}/// 确定滚动弹幕是否可以添加bool _scrollCanAddToTrack(double yPosition, double newDanmakuWidth) {for (var item in _scrollDanmakuItems) {if (item.yPosition == yPosition) {final existingEndPosition = item.xPosition + item.width;// 首先保证进入屏幕时不发生重叠,其次保证知道移出屏幕前不与速度慢的弹幕(弹幕宽度较小)发生重叠if (_viewWidth - existingEndPosition < 0) {return false;}if (item.width < newDanmakuWidth) {if ((1 -((_viewWidth - item.xPosition) / (item.width + _viewWidth))) >((_viewWidth) / (_viewWidth + newDanmakuWidth))) {return false;}}}}return true;}/// 确定顶部弹幕是否可以添加bool _topCanAddToTrack(double yPosition) {for (var item in _topDanmakuItems) {if (item.yPosition == yPosition) {return false;}}return true;}/// 确定底部弹幕是否可以添加bool _bottomCanAddToTrack(double yPosition) {for (var item in _bottomDanmakuItems) {if (item.yPosition == yPosition) {return false;}}return true;}// 基于Stopwatch的计时器同步void _startTick() async {final stopwatch = Stopwatch()..start();int lastElapsedTime = 0;while (_running && mounted) {await Future.delayed(const Duration(milliseconds: 1));int currentElapsedTime = stopwatch.elapsedMilliseconds; // 获取当前的已用时间int delta = currentElapsedTime - lastElapsedTime; // 计算自上次记录以来的时间差_tick += delta;lastElapsedTime = currentElapsedTime; // 更新最后记录的时间if (lastElapsedTime % 100 == 0) {// 移除屏幕外滚动弹幕_scrollDanmakuItems.removeWhere((item) => item.xPosition + item.width < 0);// 移除顶部弹幕_topDanmakuItems.removeWhere((item) =>((_tick - item.creationTime) > (_option.duration * 1000)));// 移除底部弹幕_bottomDanmakuItems.removeWhere((item) =>((_tick - item.creationTime) > (_option.duration * 1000)));/// 重绘静态弹幕if (mounted) {setState(() {_staticAnimationController.value = 0;});}}}stopwatch.stop();}@overrideWidget build(BuildContext context) {/// 计算弹幕轨道final textPainter = TextPainter(text: TextSpan(text: '弹幕', style: TextStyle(fontSize: _option.fontSize)),textDirection: TextDirection.ltr,)..layout();_danmakuHeight = textPainter.height;return LayoutBuilder(builder: (context, constraints) {/// 计算视图宽度if (constraints.maxWidth != _viewWidth) {_viewWidth = constraints.maxWidth;}/// 为字幕留出余量_trackCount =(constraints.maxHeight * _option.area / _danmakuHeight).floor() - 1;_trackYPositions.clear();for (int i = 0; i < _trackCount; i++) {_trackYPositions.add(i * _danmakuHeight);}return ClipRect(child: IgnorePointer(child: Stack(children: [RepaintBoundary(child: AnimatedBuilder(animation: _animationController,builder: (context, child) {return CustomPaint(painter: ScrollDanmakuPainter(_animationController.value,_scrollDanmakuItems,_option.duration,_option.fontSize,_option.fontWeight,_option.strokeWidth,_option.opacity,_danmakuHeight,_running,_tick),child: Container(),);},)),RepaintBoundary(child: AnimatedBuilder(animation: _staticAnimationController,builder: (context, child) {return CustomPaint(painter: StaticDanmakuPainter(_staticAnimationController.value,_topDanmakuItems,_bottomDanmakuItems,_option.duration,_option.fontSize,_option.fontWeight,_option.strokeWidth,_option.opacity,_danmakuHeight,_running,_tick),child: Container(),);},)),]),),);});}
}
这段代码实现了一个高性能的弹幕渲染组件(`DanmakuScreen`),核心思路是通过**自定义绘制(CustomPaint)** 替代传统Widget树渲染,结合**轨道管理**和**动画驱动**,支持滚动、顶部、底部三种弹幕类型,兼顾性能与灵活性。以下是核心逻辑拆解:### 1. 核心组件与职责划分
- **DanmakuScreen**:弹幕容器(StatefulWidget),负责管理弹幕状态、轨道计算、动画控制和生命周期,是整个弹幕系统的核心协调者。
- **DanmakuController**:外部交互接口,提供添加弹幕、更新配置、暂停/恢复、清空等方法,解耦UI与业务逻辑。
- **ScrollDanmakuPainter / StaticDanmakuPainter**:自定义画笔,分别负责绘制滚动弹幕(从右向左移动)和静态弹幕(顶部/底部固定),直接操作Canvas提升渲染性能。
- **DanmakuItem**:弹幕实体,包含位置、尺寸、文本内容、绘制缓存(`Paragraph`)等信息,是绘制的最小单元。
- **DanmakuOption**:配置项,支持自定义字体大小、权重、描边、透明度、显示时长、轨道区域比例等,灵活控制弹幕样式和行为。### 2. 弹幕生命周期管理(核心流程)
#### (1)初始化与轨道划分
- 初始化时创建动画控制器(`_animationController`用于滚动弹幕,`_staticAnimationController`用于静态弹幕)和计时器(`_startTick`),计时器通过毫秒级精度的`Stopwatch`同步时间(`_tick`),用于计算弹幕生命周期。
- 轨道计算:根据屏幕高度、弹幕高度(基于字体大小)和显示区域比例(`option.area`),垂直划分多个轨道(`_trackYPositions`),每个轨道高度等于单条弹幕高度,避免垂直方向重叠。#### (2)弹幕添加与轨道分配
- **添加逻辑**:通过`addDanmaku`方法接收弹幕内容(`DanmakuContentItem`),提前创建文本绘制缓存(`ui.Paragraph`和描边`strokeParagraph`),减少绘制时的计算开销(避免实时计算文本尺寸导致卡顿)。
- **类型区分**:- **滚动弹幕**:检查目标轨道是否有足够空间(`_scrollCanAddToTrack`),确保新弹幕与轨道内已有弹幕不重叠(通过位置和宽度计算);若开启“海量模式”(`massiveMode`)或为“自己发送的弹幕”,强制分配到随机轨道或首轨道。- **顶部/底部弹幕**:每个轨道只能有一条(`_topCanAddToTrack`/`_bottomCanAddToTrack`),避免重叠,固定显示一段时间后自动消失。#### (3)绘制与动画驱动
- **滚动弹幕**:由`ScrollDanmakuPainter`绘制,通过`_animationController`的动画值(0~1)计算位置:- 移动逻辑:根据弹幕创建时间(`creationTime`)和总时长(`option.duration`),计算当前应处的X坐标(从右向左移动,超出左边界后被清理)。- 绘制优化:使用`RepaintBoundary`隔离滚动弹幕区域,避免与静态弹幕互相触发重绘。
- **静态弹幕**:由`StaticDanmakuPainter`绘制,顶部弹幕固定在轨道顶部,底部弹幕固定在轨道底部,根据存在时间(`_tick - creationTime`)判断是否过期(超过`duration`后被清理)。#### (4)生命周期维护与清理
- 计时器(`_startTick`)每100ms触发一次清理:- 滚动弹幕:移除X坐标+宽度 < 0(完全移出左边界)的弹幕。- 静态弹幕:移除存在时间超过`duration`的弹幕。
- 应用生命周期适配:监听`AppLifecycleState`,在应用进入后台或暂停时停止动画,恢复时重启,避免后台无效消耗资源。### 3. 性能优化核心策略
- **自定义绘制**:使用`CustomPaint`直接操作Canvas,替代传统`Widget`树渲染,减少布局计算和Widget重建开销,适合高并发弹幕场景。
- **文本缓存**:提前创建`ui.Paragraph`(文本绘制对象),避免每次绘制时重新计算文本尺寸和样式,提升绘制效率。
- **轨道隔离**:垂直划分轨道,严格控制同一轨道内弹幕的位置关系,避免重叠检查的全局遍历,降低计算复杂度。
- **区域隔离**:通过`RepaintBoundary`将滚动弹幕和静态弹幕分为两个绘制区域,各自独立重绘,减少不必要的刷新。
- **按需清理**:定期移除过期或离屏弹幕,保持弹幕列表精简,避免绘制时遍历大量无效数据。### 4. 灵活性与可配置性
- **多类型支持**:同时支持滚动(从右向左)、顶部(固定)、底部(固定)三种弹幕类型,满足不同场景需求。
- **样式自定义**:通过`DanmakuOption`配置字体大小、权重、描边宽度、透明度、显示时长等,灵活适配UI风格。
- **行为控制**:支持隐藏特定类型弹幕(`hideScroll`/`hideTop`/`hideBottom`)、开启海量模式(允许重叠)、暂停/恢复/清空等操作,适配交互需求。### 总结
核心逻辑可概括为:**“轨道划分隔离弹幕 → 自定义绘制提升性能 → 动画与计时器驱动生命周期 → 配置项支持灵活定制”**。通过直接操作Canvas、提前缓存文本资源、严格轨道管理,在保证高并发弹幕流畅显示的同时,提供了丰富的自定义能力,适合视频、直播等需要高密度弹幕的场景。
4. 运行效果
- flutter_barrage:
- flutter_easy_barrage:
- flutter_barrage_craft:
- canvas_danmaku:
觉得有帮助,可以点赞或收藏