flutter 多人画板
总结一下下之前的业务需求
首先定义一下所需要的用的数据结构(因为数据量大,做了部分优化)
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
/// 数据类型定义
/// 绘制类型:直线、曲线
enum PaintType { straightLine, curvedLine }
/// 绘制状态:正在绘制、已完成、隐藏
enum PaintState { doing, done, hide }
/// Point 点类
class Point {
final double x;
final double y;
const Point({
required this.x,
required this.y,
});
factory Point.fromOffset(Offset offset, double width, double height) {
return Point(
x: double.parse((offset.dx / width).toStringAsFixed(4)),
y: double.parse((offset.dy / height).toStringAsFixed(4)),
);
}
factory Point.fromJson(
Map<String, dynamic> json, double width, double height) {
return Point(
x: json['x'],
y: json['y'],
);
}
factory Point.fromList(List<dynamic> offSetList) {
return Point(
x: offSetList[0],
y: offSetList[1],
);
}
List<double> toList() {
final List<double> offestList = [];
offestList.addAll([x, y]);
return offestList;
}
Map<String, dynamic> toJson(width, height) {
final json = Map<String, dynamic>();
json['x'] = x;
json['y'] = y;
return json;
}
double get distance => sqrt(x * x + y * y);
// Point operator -(Point other) => Point(x: x - other.x, y: y - other.y);
Offset toOffset(double width, double height) => Offset(x * width, y * height);
}
/// Line 线类
class Line {
List<Point> points = [];
PaintState state;
PaintType paintType;
double strokeWidth;
Color color;
String userId;
Line({
this.color = Colors.black,
this.strokeWidth = 1,
this.state = PaintState.doing,
this.paintType = PaintType.curvedLine,
required this.userId,
});
void paint(Canvas canvas, Paint paint, double width, double height) {
paint
..color = color
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
List<Point> pointList = [];
if (points.length < 10) {
pointList = points;
} else {
for (int i = 0; i < points.length; i++) {
if (i % 3 == 0) {
pointList.add(points[i]);
}
}
}
canvas.drawPoints(
PointMode.polygon,
pointList.map<Offset>((e) => e.toOffset(width, height)).toList(),
paint,
);
}
}
数据结构有了,就需要一块画板以及,一只笔
/// 白板 Canvas
class WhiteBoardPainter extends CustomPainter {
final WhiteBoardPen pen;
final double width;
final double height;
WhiteBoardPainter({required this.pen, this.width = 0.0, this.height = 0.0})
: super(repaint: pen);
final Paint _paint = Paint();
void paint(Canvas canvas, Size size) {
/// 遍历每一根线,绘制
for (var line in pen.lines) {
line.paint(
canvas,
_paint,
width,
height,
);
}
}
bool shouldRepaint(covariant WhiteBoardPainter oldDelegate) {
bool needRepaint = oldDelegate.pen != pen;
return needRepaint;
}
}
/// 白板画笔
class WhiteBoardPen extends ChangeNotifier {
final List<Line> _lines = [];
List<Line> get lines => _lines;
Line get activeLine => _lines.singleWhere(
(element) => element.state == PaintState.doing,
orElse: () => Line(userId: ''),
);
void pushLine(Line line) {
_lines.add(line);
}
void pushPoint(Point point) {
activeLine.points.add(point);
if (activeLine.paintType == PaintType.straightLine) {
activeLine.points = [activeLine.points.first, activeLine.points.last];
}
notifyListeners();
}
void doneLine() {
activeLine.state = PaintState.done;
notifyListeners();
}
void clear() {
for (var line in _lines) {
line.points.clear();
}
_lines.clear();
notifyListeners();
}
void clearCurrectUserLines(String userId) {
for (int i = 0; i < _lines.length; i++) {
if (_lines[i].userId == userId) {
_lines[i].points.clear();
}
}
_lines.removeWhere((element) => element.userId == userId);
notifyListeners();
}
void removeEmpty() {
_lines.removeWhere((element) => element.points.isEmpty);
}
}
下面就是业务组件的封装了,因为是多人画板以及测试们的强烈建议,就变成了 历史画板和自己正在绘制的画板
class WhiteBoard extends StatefulWidget implements FWidget {
const WhiteBoard({
key,
required this.initData,
required this.userId,
required this.patientColor,
required this.paintType,
required this.sendData,
required this.onReceiveData,
required this.onClearCanavs,
});
/// 初始画板副本数据
final List<String> initData;
/// 用户id
final String userId;
/// 用户颜色
final Color patientColor;
/// 当前绘制模式
final PaintType paintType;
/// 发送数据(接 JsonRPC)
final void Function(String data) sendData;
/// 接收数据(接 WebSocket)
final FEventHandler<String> onReceiveData;
/// 画布清除事件
final FEventHandler<String> onClearCanavs;
State<WhiteBoard> createState() => _FWhiteBoardState();
}
class _FWhiteBoardState extends State<WhiteBoard> {
/// 画布尺寸
late double canvasWidth = 0.0;
late double canvasHeight = 0.0;
/// 当前用户的画笔
WhiteBoardPen myPen = WhiteBoardPen();
/// 所有历史画笔
WhiteBoardPen historicalBrush = WhiteBoardPen();
/// 当前用户的画笔颜色
Color get myPenColor => widget.patientColor;
/// 固定的画笔粗细
static const double strokeWidth = 3.0;
/// 任务队列
late final _taskQueue = WhiteBoardTaskQueue(handler: _handleDrawTask);
void initState() {
super.initState();
widget.onReceiveData.addListener((sender, data) {
_onReceiveDrawData(data);
});
widget.onClearCanavs.addListener((sender, userId) {
_clearUserLines(userId);
});
/// 加载完后一帧绘制初始数据
WidgetsBinding.instance.addPostFrameCallback((_) {
_paintInitData();
});
}
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, constraints) {
canvasWidth = constraints.maxWidth;
canvasHeight = constraints.maxHeight;
final canvasSize = Size(canvasWidth, canvasHeight);
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onPanCancel: _onPanCancel,
child: Stack(
children: [
CustomPaint(
size: canvasSize,
painter: WhiteBoardPainter(
pen: historicalBrush,
width: canvasWidth,
height: canvasHeight,
),
),
CustomPaint(
size: canvasSize,
painter: WhiteBoardPainter(
pen: myPen,
width: canvasWidth,
height: canvasHeight,
),
),
],
)));
});
}
/// 按下时,生成新的线实体
void _onPanDown(DragDownDetails details) {
Line line = Line(
color: myPenColor,
strokeWidth: strokeWidth,
paintType: widget.paintType,
userId: widget.userId,
);
myPen.pushLine(line);
}
/// 拖动时,生成新的点实体,加入到当前线实体中
void _onPanUpdate(DragUpdateDetails details) {
myPen.pushPoint(Point.fromOffset(
details.localPosition,
canvasWidth,
canvasHeight,
));
}
/// 抬起时,发送数据
void _onPanEnd(DragEndDetails details) {
_sentDrawAction();
myPen.doneLine();
myPen.clear();
}
/// 绘制初始数据
void _paintInitData() {
for (int i = 0; i < widget.initData.length; i++) {
_onReceiveDrawDataImmediately(widget.initData[i]);
}
}
/// 发送绘制指令
void _sentDrawAction() {
List<List<double>> pointsList = [];
for (int i = 0; i < myPen.activeLine.points.length; i++) {
pointsList.add(myPen.activeLine.points[i].toList());
}
String myPenLine = jsonEncode({
"u_Id": widget.userId, // 用户id
"l_Id": "", // 线id
"color": widget.patientColor.value.toString(), // 线的颜色
"points": PointsUtil.compressPointsList(pointsList),
});
widget.sendData(myPenLine);
/// 将自己画的线也加入队列
_onReceiveDrawData(myPenLine);
}
Future<void> _handleDrawTask(Map<String, dynamic> data) async {
/// 清除时走的逻辑
if (data["isClear"] == "true") {
_clearUserLines(data["u_Id"]);
return;
}
List<dynamic> pointsList = PointsUtil.decompressPointsList(data["points"]);
Line line = Line(
color: Color(int.parse(data["color"])),
strokeWidth: strokeWidth,
userId: data["u_Id"],
);
historicalBrush.pushLine(line);
for (int i = 0; i < pointsList.length; i++) {
/// 非自己画的线加动画
if (data["u_Id"] != widget.userId) {
await Future.delayed(const Duration(milliseconds: 2));
}
historicalBrush.pushPoint(Point.fromList(
pointsList[i],
));
}
historicalBrush.doneLine();
}
/// 接收绘制数据【如果存在数据正在绘制,其他的加入绘制队列,等待当前绘制完成继续绘制】
void _onReceiveDrawData(String jsonData) async {
_taskQueue.add(jsonData);
}
/// 接收绘制数据立即绘制,无 Future.delayed
void _onReceiveDrawDataImmediately(String jsonData) {
var data = jsonDecode(jsonData);
List<dynamic> pointsList = PointsUtil.decompressPointsList(data["points"]);
Line line = Line(
color: Color(int.parse(data["color"])),
strokeWidth: strokeWidth,
userId: data["u_Id"],
);
historicalBrush.pushLine(line);
for (int i = 0; i < pointsList.length; i++) {
historicalBrush.pushPoint(Point.fromList(
pointsList[i],
));
}
historicalBrush.doneLine();
}
void _clearUserLines(String userId) {
myPen.clearCurrectUserLines(userId);
historicalBrush.clearCurrectUserLines(userId);
}
void _onPanCancel() {
myPen.removeEmpty();
}
}
当中也遇到过因为白板高并发导致的丢失任务的问题
解决方法就是
class WhiteBoardTaskQueue {
WhiteBoardTaskQueue({required this.handler});
bool _working = false;
List<String> _dataQueue = [];
final AsyncValueSetter<Map<String, dynamic>> handler;
bool get working => _working;
void add(String data) {
_dataQueue.add(data);
_start();
}
void _start() async {
if (_working) return;
_working = true;
while (_working) {
if (_dataQueue.isEmpty) {
_stop(); // 空闲暂时中止循环
break;
}
await _handleNext();
}
}
void _stop() {
_working = false;
}
Future<void> _handleNext() async {
final data = _dataQueue.removeAt(0);
try {
final map = jsonDecode(data);
await handler(map);
} catch (e) {
logger.e("[WhiteBoardTaskQueue] handle error. data: $data", e);
}
}
}
还有因为是多人画板,需要随机生成一个颜色
class ColorUtil {
static Color generateColor(String uuid) {
final hash = sha256.convert(utf8.encode(uuid)).toString();
final intHash = int.parse(hash.substring(hash.length - 10), radix: 16);
final Random random = Random(intHash);
final double r = random.nextInt(256).toDouble();
final double g = random.nextInt(256).toDouble();
final double b = random.nextInt(256).toDouble();
List<double> hsl = rgbToHsl(r, g, b);
List<int> rgb = hslToRgb(hsl[0], hsl[1], hsl[2]);
return Color.fromRGBO(
rgb[0],
rgb[1],
rgb[2],
1,
);
}
static List<double> rgbToHsl(double r, double g, double b) {
r /= 255;
g /= 255;
b /= 255;
double max1 = max(max(r, g), b);
double min1 = min(min(r, g), b);
double h = 0;
if (max1 == min1) {
h = 0; // achromatic
} else {
double d = max1 - min1;
if (max1 == r) {
h = (g - b) / d + (g < b ? 6 : 0);
} else if (max1 == g) {
h = (b - r) / d + 2;
} else if (max1 == b) {
h = (r - g) / d + 4;
}
h /= 6;
}
return [h, 0.8, 0.5];
}
static List<int> hslToRgb(double h, double s, double l) {
double r, g, b;
if (s == 0) {
r = g = b = l; // achromatic
} else {
double q = l < 0.5 ? l * (1 + s) : l + s - l * s;
double p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [r * 255, g * 255, b * 255].map((value) => value.round()).toList();
}
static double hue2rgb(double p, double q, double t) {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
}
}
class PointsUtil {
static String compressPointsList(List<List<double>> points) {
String result = '';
for (List<double> point in points) {
result +=
point[0].toStringAsFixed(4).replaceAll('0.', '').padRight(4, '0') +
point[1].toStringAsFixed(4).replaceAll('0.', '').padRight(4, '0');
}
return result;
}
static List<List<double>> decompressPointsList(String compressed) {
List<List<double>> result = [];
for (int i = 0; i < compressed.length; i += 8) {
String xString = compressed.substring(i, i + 4);
String yString = compressed.substring(i + 4, i + 8);
double x = double.parse(xString) / 10000;
double y = double.parse(yString) / 10000;
result.add([x, y]);
}
return result;
}
}
下面就是使用当前白板组件所用的demo了
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: '白板测试 Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const WhiteBoardContainer(),
);
}
}
/// 白板容器,里面带有白板状态控制按钮及其状态
class WhiteBoardContainer extends StatefulWidget {
const WhiteBoardContainer({super.key});
State<WhiteBoardContainer> createState() => _WhiteBoardContainerState();
}
class _WhiteBoardContainerState extends State<WhiteBoardContainer> {
/// 当前用户 ID
final currUserId = "123482";
/// 当前绘制模式
PaintType paintType = PaintType.curvedLine;
/// 获取到数据事件通知
FEventHandler<String> onReceiveDataHandler = FEventHandler<String>();
/// 获取到清除事件通知
FEventHandler<String> onClearCanavsHandler = FEventHandler<String>();
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter White Board Demo'),
),
body: Center(
child: Column(
children: [
/// 白板组件入口
Expanded(
child: WhiteBoard(
initData: MockData.mockCanavsCopy,
// initData: [],
userId: currUserId,
paintType: paintType,
sendData: (data) {
debugPrint("'$data',");
},
onReceiveData: onReceiveDataHandler,
onClearCanavs: onClearCanavsHandler,
patientColor: Colors.blue,
),
),
_buildOperationButton(),
],
),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
/// 操作按钮
Widget _buildOperationButton() {
return SizedBox(
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton(
onPressed: () {
setState(() {
paintType = PaintType.straightLine;
});
},
child: const Text(
'直线',
),
),
TextButton(
onPressed: () {
setState(() {
paintType = PaintType.curvedLine;
});
},
child: const Text(
'曲线',
),
),
TextButton(
onPressed: () {
onClearCanavsHandler.emit(this, currUserId);
},
child: const Text(
'清除',
),
),
TextButton(
onPressed: () {
onReceiveDataHandler.emit(this, MockData.mockWSNotificationA);
},
child: const Text(
'模拟收到数据A',
),
),
TextButton(
onPressed: () {
onReceiveDataHandler.emit(this, MockData.mockWSNotificationB);
},
child: const Text(
'模拟收到数据B',
),
),
],
),
);
}
}
class MockData {
static const String mockWSNotificationA =
'{"u_Id":"123452","l_Id":"","points":"531829655285299852643030521030845135314951023182502732364995326849193323485533774833339847793442475834524736347447263485"}';
static const String mockWSNotificationB =
'{"u_Id":"123452","l_Id":"","points":"1905175319161742192717321959172119811710201316992034169920561699208816882099168821311688216416772185167722071677222816772239167722711677229316772325167723571688237916882422169924431710247617102530173225511732257317422594175326161764263717642648178626591786267017972680180726911818270218402713185127131872271318832713189427231916272319372723195927231970272320022713201327022056269120782659211026482121261621652594217525732197256222192540222925082251248722622454228424432294242223052390231623682327235723272325233823042338228223382271233822282338221723382207233821852338217423382174232721742316217423052174229421742284217422622185225121852240220722292207221922172208223921972260218622712175233621432357214323792143240021322422213224542121248721212519212125512121258321212616212126592121269121212734212127882121283121212885212129492121301421323057214331002154313221653175217531972186320821863208219732192197321922083229222932292240322922623229228432292294321923383219235932082370317524033165242431542424311124573089246830792478303625003003250029712500297124892971247829712457297124353014238130362359308923163154228432082251331522193391219734662175355221653628215436602143375721433843214339502143404721434144214341872143430621434392215444242165452121864596220846182219467222294682224047152262473622734736229447362305473623384736234847362381473624034726244647042468470424894650252246392543461825544543259745102619447826194403264143702652434926524284266242632662425226624241266242412652424126414241263042412597424125764273253243062500436024574435241345322359469323274801231649092305517823055318230554792316561923385802237059532403609324356211246862972489639425226416253264262532"}';
static const List<String> mockCanavsCopy = [
'{"u_Id":"123452","l_Id":"","points":"10121970107619481173193712491926135619051453189415611883161518611690185117121851172218401722182917221818"}',
'{"u_Id":"123452","l_Id":"","points":"1378162313781677137817421378178613781872137819701378204513782154137822401378231613782457137825651378265213672738135628251356290013462955134630191346306313463084134631061346312813463139"}',
'{"u_Id":"123452","l_Id":"","points":"12922262127023051259232712382392119524351173247811302543111925651098260810762630107626521066265210662662106626521066263010662608"}',
'{"u_Id":"123452","l_Id":"","points":"143220781464210015072132156121651615220816582251167922621712229417442327176523381765234817762348"}',
'{"u_Id":"123452","l_Id":"","points":"184117421916173220021721212117102217168823791667251916452626163427341602280915912842159128631580"}',
'{"u_Id":"123452","l_Id":"","points":"21961494218515152185155821851591217416342174167721741710217417422174179721741861218518942185194821961970219620022196203521962045"}',
'{"u_Id":"123452","l_Id":"","points":"2497156924971602249716232497166724871699247617322454181824431851243318832422191624111937240019592390198123792013236820242368203523572045234720452347205623472067"}',
'{"u_Id":"123452","l_Id":"","points":"184122081862222918842273191622941927231619482348197023701981239219812403200224242002244620242457203424892045251120562522206725222067253220672543"}',
'{"u_Id":"123452","l_Id":"","points":"21532186225021542325214324112121249721102573210026372089268020892713208927342078274520782756207827562089275621102745213227342165271321972680224026592273262623162605234825832370257323922573240325622413255124352551244625512457"}',
'{"u_Id":"123452","l_Id":"","points":"2153235921742359220723592228235922502359227123592293235923042359232523592336235923472359"}',
'{"u_Id":"123452","l_Id":"","points":"21962532222825322250253222822532231425322347253223792532240025322411253224222532244325322454253224652532"}',
'{"u_Id":"123452","l_Id":"","points":"1722287918082868188428571970284621532825227128142390280324872792256227712626276027022749"}',
'{"u_Id":"123452","l_Id":"","points":"227126522250269522282738219627812153283521312900208829552056300920343030201330741981311719703139195931601959317119593182"}',
'{"u_Id":"123452","l_Id":"","points":"233629442390296524652998253030302605306326913095278831282863316029173182294932032960320329713203"}',
'{"u_Id":"123442","l_Id":"","points":"5662267357482662581326525899264160172630610326196189260862542597628625976329258763292576"}',
'{"u_Id":"123442","l_Id":"","points":"598522085985221959852229598522515985226259852273598522945985231659852327598523385985235959852370598524035985241359852435598524575985246859852500599625225996254359962565599625975996261959962641599626735996269559962727599627605996279259962803599628355996285759962868599628795996291159962922598529555985296559852998598530305985304159853084598530955974313959743149597431825974319359743214597432475974326859633301596333125963333359533366595333875953340959533442595334525942346359423496594235065942352859313561593135715931359359203604592036155920365859203669592036905920370159203712592037345920374559103755591037665910377759103799591038205899384258993853589938645899387458883874588838855888389658883907588839185888393958773939587739505877396158773972587739835867398358563972584539395834391858233896581338745802385358023820578037995780378857703766577037555770374557593745"}',
'{"u_Id":"123442","l_Id":"","points":"571634425759342057913409582333875867336658993366592033445942333359533333597433235985332359853312600633016017330160283290603932906050327960603268607132686082325860933258609332476103324761033236"}',
'{"u_Id":"123442","l_Id":"","points":"6340298763402998634030196340304163403063634030956340312863293149632931716319320363193225631932586308327963083312629733446286336662863377628633986286342062863452628634636286347462863485628634966286350662863517628635286286353962973539631935506340356163623561636235716372357163833582639435826405358264163582641635716426356164373550644835286448350664693485648034746480345264913420651233666512335565233333652333236534331265343301653432906534327965343268"}',
'{"u_Id":"123442","l_Id":"","points":"65772749658827606598277165982803663128576642287966422890665229226663293366632944666329656674297666852987"}',
'{"u_Id":"123442","l_Id":"","points":"71692413716924467169245771692522716925767169259771472673713726957115276070942792706129007051294470083052698630956943319369213236687833236857337768353409678134856771352867283615670636476685368066523755663137776620381065883864657738856566390765553929654539506545396165453950654539396545390765553896"}',
'{"u_Id":"123442","l_Id":"","points":"7008320370293236707232907115334471583398722334747266351773203571736336047374362674063658743836807438370174603712746037237470372374703734"}',
];
}