flutter 多人画板组件的实现

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"}',
  ];
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值