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 widget
final Widget child;/// time in seconds of bullet show in screen
final int speed;/// used to adjust speed for each channel
final 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 subtitles
final int safeBottomHeight;/// [disable] by default, set to true will overwrite other bullets
final bool massiveMode;/// used to make barrage tidy
final double maxBulletHeight;/// enable debug mode, will display a debug panel with information
final 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;
}
}@override
State<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 left
double 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;
}@override
String 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 exists
int? _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 enabled if (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 now
end ??= 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 too if (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 time final 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 width if (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); });}
@override
void 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(() {}); }}
@override
void 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();}
@override
void dispose() {
debugPrint('[$TAG] dispose');
_cleaner.cancel();
_controller.clear();
_controller.removeListener(handleBullets);
if (widget.selfCreatedController) {
_controller.dispose();
}
super.dispose();
}@override
Widget 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 minutes
Map<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));
}
});
}@override
String 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);@override
String 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,
);@override
String 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 state
void reset(int showedTimeBefore) {
value = value.copyWith(
showedTimeBefore: showedTimeBefore, waitingList: [], processedSize: 0);
}void updateChannel(Function(int usedChannel) onUpdate) {
_usedChannel = onUpdate(_usedChannel);
}/// clear all firing bullets
void clear() {
/// reset all widgets animation and clear the list
widgets.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);
}@override
Future<void> dispose() async {
if (!_isDisposed) {
_timer?.cancel();
}
_isDisposed = true;
timelineNotifier?.dispose();
enabledNotifier.dispose();
super.dispose();
}
}class Bullet implements Comparable<Bullet> {
final Widget child;/// in milliseconds
final int showTime;const Bullet({required this.child, this.showTime = 0});
@override
String toString() {
return 'Bullet{child: child, showTime: showTime}';
}@override
int compareTo(Bullet other) {
return showTime.compareTo(other.showTime);
}
}这段代码实现了一个 Flutter 弹幕组件,核心思路是通过时间线管理弹幕触发、通道划分避免重叠、动画控制移动轨迹,最终实现从右向左流动的弹幕效果。以下是核心逻辑拆解:
- 核心组件与职责划分
BarrageWall:弹幕容器组件(StatefulWidget),负责渲染弹幕和管理视图状态,依赖控制器处理业务逻辑。
BarrageWallController:控制器,核心角色是管理弹幕数据(待显示、已显示)、时间线(触发时机)和资源释放。
Bullet:弹幕实体,包含要显示的 Widget 和触发时间(showTime,毫秒级)。
BulletPos:弹幕位置跟踪器,记录弹幕所在通道、当前位置、宽度等,用于判断是否释放通道。 - 弹幕生命周期管理(核心流程)
(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 检查一次已完成的动画控制器,销毁并从列表中移除,避免内存泄漏。
当容器销毁或控制器禁用时,清理所有动画资源并重置通道状态。 - 关键特性支持
时间线同步:支持外部时间通知(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;///弹幕从某个位置开始出现,默认是0
final 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);@override
State<StatefulWidget> createState() {
return EasyBarrageState();
}
}class EasyBarrageState extends State<EasyBarrage> {
List<BarrageLineController> controllers = [];
late EasyBarrageController _controller;
final Random _random = Random();Timer? _timeline;
@override
void 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();}
@override
void 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); } }}
@override
Widget 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() {}
@override
State<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;@override
void initState() {
controller = widget.controller;
controller.addListener(handleBarrages);
controller.tickNotifier.addListener(() {
handleWaitingBarrages();
});super.initState();}
@override
void 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(() {}); }}
@override
Widget 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),核心思路是通过垂直划分固定轨道、轨道内弹幕队列管理、动画控制移动方向与速度,实现可定制的弹幕流效果。以下是核心逻辑拆解:
- 核心组件与职责划分
EasyBarrage:弹幕容器(StatefulWidget),负责整体布局(划分多行轨道),协调轨道控制器与数据更新。
EasyBarrageController:全局控制器,管理待发送的弹幕数据(支持普通列表和按轨道划分的映射),提供发送、清空、速度控制等接口。
BarrageLine:单轨道组件(每行弹幕),负责渲染当前轨道的弹幕,通过动画控制弹幕移动。
BarrageLineController:单轨道控制器,管理该轨道的弹幕队列、动画资源和位置跟踪,确保轨道内弹幕不重叠。
BarrageItem:弹幕实体,包含要显示的 Widget、宽度(提前指定)和唯一 ID(用于跟踪)。 - 弹幕生命周期管理(核心流程)
(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到结束位置fixedWidth2)。
速度控制:基础时长由duration配置,支持通过updateSpeed动态修改动画时长(改变速度)。
(4)位置更新与资源清理
实时位置跟踪:通过BarrageItemPosition记录弹幕的动画值、宽度等,AnimatedBuilder实时更新位置:
从右到左:widthPos = 轨道宽度 - 动画值(从右侧进入,向左移动)。
从左到右:widthPos = 动画值 - 弹幕宽度(从左侧进入,向右移动)。
资源清理:当弹幕完全移出轨道(动画完成或位置超出轨道宽度),销毁动画控制器并从列表中移除,避免内存泄漏。 - 关键特性支持
多轨道隔离:垂直划分为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;
@override
State<BarrageView> createState() => _BarrageViewState();
}class _BarrageViewState extends State<BarrageView> {
@override
void initState() {
super.initState();
widget.controller.setState = setState;
}@override
void 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],
),
);
}@override
Widget 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,
});@override
State<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;@override
void 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 应用后台或熄屏导致的动画问题
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if ([
AppLifecycleState.paused,
AppLifecycleState.detached,
].contains(state) &&
_pauseInBackground &&
_running) {
_pauseInBackground = true;
pause();
} else if (_pauseInBackground) {
_pauseInBackground = false;
resume();
}
super.didChangeAppLifecycleState(state);
}@override
void 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();}
@override
Widget 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:


觉得有帮助,可以点赞或收藏