当前位置: 首页 > news >正文

Canvas图形编辑器-数据结构与History(undo/redo)

Canvas图形编辑器-数据结构与History(undo/redo)

这是作为 社区老给我推Canvas,于是我也学习Canvas做了个简历编辑器 的后续内容,主要是介绍了对数据结构的设计以及History能力的实现。

  • 在线编辑: https://windrunnermax.github.io/CanvasEditor
  • 开源地址: https://github.com/WindrunnerMax/CanvasEditor

关于Canvas简历编辑器项目的相关文章:

  • 社区老给我推Canvas,我也学习Canvas做了个简历编辑器
  • Canvas图形编辑器-数据结构与History(undo/redo)
  • Canvas图形编辑器-我的剪贴板里究竟有什么数据
  • Canvas简历编辑器-图形绘制与状态管理(轻量级DOM)
  • Canvas简历编辑器-Monorepo+Rspack工程实践

描述

对于编辑器而言,History也就是undoredo是必不可少的能力,实现历史记录的方法通常有两种:

  1. 存储全量快照,也就是说我我们每进行一个操作,都需要将全量的数据通常也就是JSON格式的数据存到一个数组里,如果用户此时触发了redo就将全量的数据取出应用到Editor对象当中。这种实现方式的优点是简单,不需要过多的设计,缺点就是一旦操作的多了就容易炸内存。

  2. 基于Op的实现,Op就是对于一个操作的原子化记录,举个例子如果将图形A向右移动3px,那么这个Op就可以是type: "MOVE", offset: [3, 0],那么如果想要做回退操作依然很简单,只需要将其反向操作即type: "MOVE", offset: [-3, 0]就可以了,这种方式的优点是粒度更细,存储压力小,缺点是需要复杂的设计以及计算。

既然我们是从零开始设计一个编辑器,那么大概率是不会采用方案1的,我们更希望能够设计原子化的Op来实现History,所以从这个方向开始我们就需要先设计数据结构。

数据结构

我特别推荐大家去看一下 quill-delta 的数据结构设计,这个数据结构的设计非常棒,其可以用来描述一篇富文本,同时也可以用来构建change对富文本做完整的增删改操作,对于数据的composeinvertdiff等操作也一应俱全,而且quill-delta也可以是富文本OT协同算法的实现,这其中的设计还是非常牛逼的。

其实我之前也没有设计过数据结构,更不用谈设计Op去实现历史记录功能了,所以我在设计数据结构的时候是抓耳挠腮、寝食难安,想设计出 quill-delta 这种级别的数据描述几乎是不可能了,所以只能依照我的想法来简单地设计,这其中有很多不完善的地方后边可能还会有所改动。

因为之前也没有接触过Canvas,所以我的主要目标是学习,所以我希望任何的实现都以尽可能简单的方向走。那么在这里我认为任何元素都是矩形,因为绘制矩阵是比较简单的,所以图形元素基类的x, y, width, height属性是确定的,再加上还有层级结构,那么就再加一个z,此外由于需要标识图形,所以还需要给其设置一个id

class Delta {public readonly id: string;protected x: number;protected y: number;protected z: number;protected width: number;protected height: number;
}

因为我想做一个插件化的实现,也就是说所有的图形都应该继承这个类,那么这个自定义的函数体肯定是需要存储自己的数据,所以在这里加一个attrs属性,又因为想简单实现整个功能,所以这个数据类型就被定义为Record<string, string>。因为是插件化的,每个图形的绘制应该由子类来实现,所以需要定义绘制函数的抽象方法,于是一个数据结构就这么设计好了,关于插件化的设计我们后续可以再继续聊。

abstract class Delta {public readonly id: string;protected x: number;protected y: number;protected z: number;protected width: number;protected height: number;public attrs: DeltaAttributes;public abstract drawing: (ctx: CanvasRenderingContext2D) => void;
}

那么现在已经有了基本的数据结构,我们可以设想一下究竟应该有哪几种操作,经过考虑大概无非是 插入INSERT、删除DELETE、移动MOVE、调整大小RESIZE、修改属性REVISE,这五个Op就可以覆盖我们对于当前编辑器图形的所有操作了,所以我们后续的设计都要围绕着这五个操作来进行。

看起来其实并不难,但实际上想要将其设计好并不容易,因为我们目标是History所以我们不光要顾及正向的操作,还需要设计好invert也就是反向操作,依旧以之前的MOVE操作举例,我们移动一个元素可以使用MOVE(3, 0),反向操作就可以直接生成也就是MOVE(3, 0).invert = MOVE(-3, 0),那么RESIZE操作呢,尤其是在多选操作时的RESIZE,我们需要想办法让其能够实现invert操作,一种方法是记录每个点的移动距离,但是这样对于每个Op存储的信息有点过多,我们在构造一个正向的Op时也需要将相关的数据拉到Op中,同样对于REVISE而言我们需要将属性的前值和后值都放在Op中才可以继续执行。

那么如何比较好的解决这个问题呢,很明显如果我们想用轻量的数据来承载内容,那么先前的数据在不一定会使用的情况下我们是没必要存储的,那是不是可以自动提取相关的内容作为invert-op呢,当然是可以的,我们可以在进行invert的时候,将未操作前的Delta一并作为参数传入就好了,我们可以来验证一下,我们的函数签名将会是Op.invert(Delta) = Op'

// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2})
// Next DeltaSet
[{id: "xxx", x: x1 + x2, y: y1 + y2, width: w1, height: w1}]
// Invert InsertOp
RESIZE({id: "xxx", x: -x2, y: -y2})// Prev DeltaSet
[{id: "xxx", x: x1, y: y1, width: w1, height: h1}]
// ResizeOp
RESIZE({id: "xxx", x: x2, y: y2, width: w2, height: h2})
// Next DeltaSet
[{id: "xxx", x: x2, y: y2, width: w2, height: h2}]
// Invert InsertOp
RESIZE({id: "xxx", x: x1, y: y1, width: w1, height: h1})

看起来是没有问题的,所以我们现在可以设计全量的OpInvert方法了,在这里因为我最开始是预计要设计组合也就是将几个图形组合在一起操作的能力,所以还预留了一个parentId作为后期开发拓展用,但是暂时是用不上的所以这个字段暂时可以忽略。下面的Invert实际上就是case by case地进行转换,INSERT -> DELETEDELETE -> INSERTMOVE -> MOVERESIZE -> RESIZEREVISE -> REVISE。这其中的DeltaSet可以理解为当前的所有Delta数据,类型签名类似于Record<string, Delta>,是扁平的结构,便于数据查找。

export type OpPayload = {[OP_TYPE.INSERT]: { delta: Delta; parentId: string };[OP_TYPE.DELETE]: { id: string; parentId: string };[OP_TYPE.MOVE]: { ids: string[]; x: number; y: number };[OP_TYPE.RESIZE]: { id: string; x: number; y: number; width: number; height: number };[OP_TYPE.REVISE]: { id: string; attrs: DeltaAttributes };
};export class Op<T extends OpType> {public readonly type: T;public readonly payload: OpPayload[T];constructor(type: T, payload: OpPayload[T]) {this.type = type;this.payload = payload;}public invert(prev: DeltaSet) {switch (this.type) {case OP_TYPE.INSERT: {const payload = this.payload as OpPayload[typeof OP_TYPE.INSERT];const { delta, parentId } = payload;return new Op(OP_TYPE.DELETE, { id: delta.id, parentId });}case OP_TYPE.DELETE: {const payload = this.payload as OpPayload[typeof OP_TYPE.DELETE];const { id, parentId } = payload;const delta = prev.get(id);if (!delta) return null;return new Op(OP_TYPE.INSERT, { delta, parentId });}case OP_TYPE.MOVE: {const payload = this.payload as OpPayload[typeof OP_TYPE.MOVE];const { x, y, ids } = payload;return new Op(OP_TYPE.MOVE, { ids, x: -x, y: -y });}case OP_TYPE.RESIZE: {const payload = this.payload as OpPayload[typeof OP_TYPE.RESIZE];const { id } = payload;const delta = prev.get(id);if (!delta) return null;const { x, y, width, height } = delta.getRect();return new Op(OP_TYPE.RESIZE, { id, x, y, width, height });}case OP_TYPE.REVISE: {const payload = this.payload as OpPayload[typeof OP_TYPE.REVISE];const { id, attrs } = payload;const delta = prev.get(id);if (!delta) return null;const prevAttrs: DeltaAttributes = {};for (const key of Object.keys(attrs)) {prevAttrs[key] = delta.getAttr(key);}return new Op(OP_TYPE.REVISE, { id, attrs: prevAttrs });}default:break;}return null;}
}

History

既然我们已经设计好了基于Op的原子化操作以及数据结构,那么紧接着我们就可以开始做History能力了,在这里首先需要注意我们先前对于Invert的思想是让其根据DeltaSet自动先生成InvertOp,在这里我们可以有两种方案来实现。

  1. 第一种方式是在应用Op之前我们先根据当前的DeltaSet自动生成一个InvertOp,然后将这个Op交给History模块存储起来作为Undo的组操作即可。

  2. 第二种方式是我们在应用Op之前首先生成一遍新的Previous DeltaSet,是一个immer的副本,然后将Prev DeltaSet以及Next DeltaSet一并作为OnChangeEvent交给History模块进行后续的操作。

最终我是选择了方案二作为整体实现,倒是没有什么具体依据,只是觉得这个immer的副本可能不仅会在这里使用,作为事件的一部分分发先前的数据值我认为是合理的,所以在应用Op的时候大致实现如下。

public apply(op: OpSetType, applyOptions?: ApplyOptions) {const options = applyOptions || { source: "user", undoable: true };const previous = new DeltaSet(this.editor.deltaSet.getDeltas());switch (op.type) {// 根据不同的`Op`执行不同的操作}this.editor.event.trigger(EDITOR_EVENT.CONTENT_CHANGE, {previous,current: this.editor.deltaSet,changes: op,options,});
}

其实我们也可以看到,整个编辑器内部的通信是依赖于event这个模块的,也就是说这个apply函数不会直接调用History的相关内容,我们的History模块是独立挂载CONTENT_CHANGE事件的。那么紧接着,我们需要设计History模块的数据存储,我们先来明确一下想要实现的内容,现在原子化的Op已经设计好了,所以在设计History模块时就不需要全量保存快照了,但是如果每个操作都需要并入History Stack的话可能并不是很好,通常都是有NOp的一并Undo/Redo,所以这个模块应该有一个定时器与缓存数组还有最大时间,如果在N毫秒秒内没有新的Op加入的话就将Op并入History Stack,还有就是常规的undo stack以及redo stack,栈存储的内容也不应该很大,所以还需要设置最大存储量。

export class History {private readonly DELAY = 800;private readonly STACK_SIZE = 100;private temp: OpSetType[];private undoStack: OpSetType[][];private redoStack: OpSetType[][];private timer: ReturnType<typeof setTimeout> | null;
}

前边也提到过我们都是通过事件来进行通信的,所以这里需要先挂载事件,并且在这里将InvertOp构建好,将其置入批量操作的缓存中。

  constructor(private editor: Editor) {this.editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange, 10);}destroy() {this.editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange);}private onContentChange = (e: ContentChangeEvent) => {if (!e.options.undoable) return void 0;this.redoStack = [];const { previous, changes } = e;const invert = changes.invert(previous);if (invert) {this.temp.push(invert);if(!this.timer) {this.timer = setTimeout(this.collectImmediately, this.DELAY);}}};

后来我在思考一个问题,如果这N毫秒内用户进行了Undo操作应该怎么办,后来想想实际上很简单,此时只需要清除定时器,将暂存的Op[]立即放置于Redo Stack即可。

  private collectImmediately = () => {if (!this.temp.length) return void 0;this.undoStack.push(this.temp);this.temp = [];this.redoStack = [];this.timer && clearTimeout(this.timer);this.timer = null;if (this.undoStack.length > this.STACK_SIZE) this.undoStack.shift();};

后边就是实际进行redoundo的操作了,只不过在这里批量操作是使用循环每个Op都需要单独Apply的,这样感觉并不是很好,毕竟需要修改多次,虽然后边的渲染我只会进行一次批量渲染,但是这里事件触发的次数有点多,另外这里有个点还需要注意,我们在History模块里进行的操作,本身不应该再记入History中,所以这里还有一个ApplyOptions的设置需要注意。此外,在undo之后需要将这部分内容再次invert之后入redo stack,反过来也是一样的,此时我们直接取当前编辑器的DeltaSet即可。

  public undo() {this.collectImmediately();if (!this.undoStack.length) return void 0;const ops = this.undoStack.pop();if (!ops) return void 0;this.editor.canvas.mask.clearWithOp();this.redoStack.push(ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]);this.editor.logger.debug("UNDO", ops);ops.forEach(op => this.editor.state.apply(op, { source: "undo", undoable: false }));}public redo() {if (!this.redoStack.length) return void 0;const ops = this.redoStack.pop();if (!ops) return void 0;this.editor.canvas.mask.clearWithOp();this.undoStack.push(ops.map(op => op.invert(this.editor.deltaSet)).filter(Boolean) as OpSetType[]);this.editor.logger.debug("REDO", ops);ops.forEach(op => this.editor.state.apply(op, { source: "redo", undoable: false }));}

最后

本文我们介绍总结了我们的图形编辑器中数据结构的设计以及History模块的实现,虽然暂时不涉及到Canvas本身,但是这都是作为编辑器本身的基础能力,也是通用的能力可以学习。后边我们可以介绍的能力还有很多,例如复制粘贴模块、画布分层、事件管理、无限画布、按需绘制、性能优化、焦点控制、参考线、富文本、快捷键、层级控制、渲染顺序、事件模拟、PDF排版等等,整体来说还是比较有意思的,欢迎关注我并留意后续的文章。

相关文章:

Canvas图形编辑器-数据结构与History(undo/redo)

Canvas图形编辑器-数据结构与History(undo/redo) 这是作为 社区老给我推Canvas&#xff0c;于是我也学习Canvas做了个简历编辑器 的后续内容&#xff0c;主要是介绍了对数据结构的设计以及History能力的实现。 在线编辑: https://windrunnermax.github.io/CanvasEditor开源地…...

阿里云Centos7下编译glibc

编译glibc 原来glibc版本 编译前需要的环境: CentOS7 gcc 8.3.0 gdb 8.3.0 make 4.0 binutils 2.39 (ld -v) python 3.6.8 其他看INSTALL, 但有些版本也不易太高 wget https://mirrors.aliyun.com/gnu/glibc/glibc-2.37.tar.gz tar -zxf glibc-2.37.tar.gz cd glibc-2.37/ …...

UE5数字孪生系列笔记(四)

场景的切换 创建一个按钮的用户界面UMG 创建一个Actor&#xff0c;然后将此按钮UMG添加到组件Actor中 调节几个全屏的背景 运行结果 目标点切换功能制作 设置角色到这个按钮的位置效果 按钮被点击就进行跳转 多个地点的切换与旋转 将之前的目标点切换逻辑替换成旋转的逻…...

品牌故事化:Kompas.ai如何塑造深刻的品牌形象

在这个信息爆炸的时代&#xff0c;品牌故事化已经成为企业塑造独特形象、与消费者建立情感联系的重要手段。一个引人入胜的品牌故事不仅能够吸引消费者的注意力&#xff0c;还能够在消费者心中留下持久的印象&#xff0c;建立起强烈的情感连接。本文将深入探讨品牌故事化对于构…...

5g和2.4g频段有什么区别

运行的频段不同 2.4G和5G频段的主要区别在于它们运行的频段不同&#xff0c;2.4G频段运行在2.4GHz的频段上&#xff0c;而5G频段&#xff08;这里指的是5GHz频段&#xff09;运行在5GHz的频段上。12 这导致了两者在传输速度、覆盖范围、抗干扰能力等方面的明显差异。以下是详…...

交通管理在线服务系统|基于Springboot的交通管理系统设计与实现(源码+数据库+文档)

交通管理在线服务系统目录 目录 基于Springboot的交通管理系统设计与实现 一、前言 二、系统功能设计 三、系统实现 1、用户信息管理 2、驾驶证业务管理 3、机动车业务管理 4、机动车业务类型管理 四、数据库设计 1、实体ER图 五、核心代码 六、论文参考 七、最新计…...

konva.js 工具类

konva.js 工具类 class KonvaCanvas {/*** 初始化画布* param {String} domId 容器dom id*/constructor(domId) {this.layer null;this.stage null;this.scale 1;this.init(domId);}/*** 聚焦到指定元素* param {String} elementId 元素dom id*/focusOn(elementId) {if (!t…...

php未能在vscode识别?

在设置里搜php&#xff0c;找到settings.json&#xff0c;设置你的安装路径即可。 成功...

解读MongoDB官方文档获取mongo7.0版本的安装步骤与基本使用

mongo式一款NOSQL数据库&#xff0c;用于存储非结构化数据&#xff0c;mongo是一种用于存储json的数据数据&#xff0c;可以通过mongo提供的命令解析json获取想要的值。 数据模型 了解关系数据库会很熟悉database,table,row,column的概念&#xff0c;分别是数据库&#xff0c…...

【数据结构|C语言版】顺序表

前言1. 初步认识数据结构2. 线性表3. 顺序表3.1 顺序表的概念3.1 顺序表的分类3.2 动态顺序表的实现 结语 前言 各位小伙伴大家好&#xff01;小编来给大家讲解一下数据结构中顺序表的相关知识。 1. 初步认识数据结构 【概念】数据结构是计算机存储、组织数据的⽅式。 数据…...

Unity类银河恶魔城学习记录12-17 p139 In game UI源代码

Alex教程每一P的教程原代码加上我自己的理解初步理解写的注释&#xff0c;可供学习Alex教程的人参考 此代码仅为较上一P有所改变的代码 【Unity教程】从0编程制作类银河恶魔城游戏_哔哩哔哩_bilibili UI.cs using UnityEngine;public class UI : MonoBehaviour {[SerializeFie…...

MongoDB学习【一】MongoDB简介和部署

MongoDB简介 MongoDB是一种开源的、面向文档的、分布式的NoSQL数据库系统&#xff0c;由C语言编写而成。它的设计目标是为了适应现代Web应用和大数据处理场景的需求&#xff0c;提供高可用性、横向扩展能力和灵活的数据模型。 主要特点&#xff1a; 文档模型&#xff1a; Mon…...

html 引入vue Element ui 的方式

第一种&#xff1a;使用CDN的方式引入 <!--引入 element-ui 的样式&#xff0c;--> <link rel"stylesheet" href"https://unpkg.com/element-ui/lib/theme-chalk/index.css"> <!-- 必须先引入vue&#xff0c; 后使用element-ui --> <…...

曾经备受追捧的海景房,为何如今却没人要了?

独家首发 ------------ 全国多地的海景房如威海乳山、惠州大亚湾、北海银滩等多地的海景房如今大跌也难以卖出&#xff0c;与当初各地对海景房的追捧形成了鲜明对比&#xff0c;为何这些海景房变成如此样子&#xff0c;在于现实与宣传存在着很大的区别。 曾几何时面朝大海鸟语花…...

[docker] 镜像部分补充

[docker] 镜像部分补充 这里补充一下比较少用的&#xff0c;关于镜像的内容 检查镜像 ❯ docker images REPOSITORY TAG IMAGE ID CREATED SIZE <none> <none> ca61c1748170 2 hours ago 1.11GB node latest 5212d…...

Android(Kotlin) 委托(by) 封装 SharedPreferences

在 Kotlin 中&#xff0c;委托是一种通过将自身的某个功能交给另一个对象来实现代码重用的技术。通过委托&#xff0c;我们可以将某个属性或方法的实现委托给另一个对象&#xff0c;从而减少重复代码的编写。委托可以用于实现多重继承、代码复用和扩展现有类的功能。 Kotlin 中…...

2022年蓝桥杯省赛软件类C/C++B组----积木画

想借着这一个题回顾一下动态规划问题的基本解法&#xff0c;让解题方法清晰有条理&#xff0c;希望更多的人可以更轻松的理解动态规划&#xff01; 目录 【题目】 【本题解题思路】 【DP模版】 总体方针&#xff1a; 具体解题时的套路&#xff1a; 【题目】 【本题解题思…...

Python数据挖掘项目开发实战:使用朴素贝叶斯进行社会媒体挖掘

注意&#xff1a;本文下载的资源&#xff0c;与以下文章的思路有相同点&#xff0c;也有不同点&#xff0c;最终目标只是让读者从多维度去熟练掌握本知识点。 Python数据挖掘项目开发实战&#xff1a;使用朴素贝叶斯进行社会媒体挖掘 一、项目背景与目标 在社交媒体时代&…...

【DM8】ET SQL性能分析工具

通过统计SQL每个操作符的时间花费&#xff0c;从而定位到有性能问题的操作&#xff0c;指导用户去优化。 开启ET工具 INI参数&#xff1a; ENABLE_MONITOR1 MONITOR_SQL_EXEC1 查看参数 select * FROM v$dm_ini WHERE PARA_NAMEMONITOR_SQL_EXEC;SELECT * FROM v$dm_ini WH…...

001-谷粒商城-微服务剖析

1、架构图 还是很强的&#xff0c;该有的都有 2、微服务模块 SpringCloudAlibaba组件包括 SentinelNacosRocketMQSeata 搭配SpringCloudAlibaba组件 OpenFeignGateWayRibbn gateway使用了SpringWebFlux&#xff0c;前几天研究到&#xff0c;为什么springboot不直接使用Spri…...

变量 varablie 声明- Rust 变量 let mut 声明与 C/C++ 变量声明对比分析

一、变量声明设计&#xff1a;let 与 mut 的哲学解析 Rust 采用 let 声明变量并通过 mut 显式标记可变性&#xff0c;这种设计体现了语言的核心哲学。以下是深度解析&#xff1a; 1.1 设计理念剖析 安全优先原则&#xff1a;默认不可变强制开发者明确声明意图 let x 5; …...

rknn优化教程(二)

文章目录 1. 前述2. 三方库的封装2.1 xrepo中的库2.2 xrepo之外的库2.2.1 opencv2.2.2 rknnrt2.2.3 spdlog 3. rknn_engine库 1. 前述 OK&#xff0c;开始写第二篇的内容了。这篇博客主要能写一下&#xff1a; 如何给一些三方库按照xmake方式进行封装&#xff0c;供调用如何按…...

UE5 学习系列(三)创建和移动物体

这篇博客是该系列的第三篇&#xff0c;是在之前两篇博客的基础上展开&#xff0c;主要介绍如何在操作界面中创建和拖动物体&#xff0c;这篇博客跟随的视频链接如下&#xff1a; B 站视频&#xff1a;s03-创建和移动物体 如果你不打算开之前的博客并且对UE5 比较熟的话按照以…...

【ROS】Nav2源码之nav2_behavior_tree-行为树节点列表

1、行为树节点分类 在 Nav2(Navigation2)的行为树框架中,行为树节点插件按照功能分为 Action(动作节点)、Condition(条件节点)、Control(控制节点) 和 Decorator(装饰节点) 四类。 1.1 动作节点 Action 执行具体的机器人操作或任务,直接与硬件、传感器或外部系统…...

Maven 概述、安装、配置、仓库、私服详解

目录 1、Maven 概述 1.1 Maven 的定义 1.2 Maven 解决的问题 1.3 Maven 的核心特性与优势 2、Maven 安装 2.1 下载 Maven 2.2 安装配置 Maven 2.3 测试安装 2.4 修改 Maven 本地仓库的默认路径 3、Maven 配置 3.1 配置本地仓库 3.2 配置 JDK 3.3 IDEA 配置本地 Ma…...

Java线上CPU飙高问题排查全指南

一、引言 在Java应用的线上运行环境中&#xff0c;CPU飙高是一个常见且棘手的性能问题。当系统出现CPU飙高时&#xff0c;通常会导致应用响应缓慢&#xff0c;甚至服务不可用&#xff0c;严重影响用户体验和业务运行。因此&#xff0c;掌握一套科学有效的CPU飙高问题排查方法&…...

Java 二维码

Java 二维码 **技术&#xff1a;**谷歌 ZXing 实现 首先添加依赖 <!-- 二维码依赖 --><dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.1</version></dependency><de…...

高效线程安全的单例模式:Python 中的懒加载与自定义初始化参数

高效线程安全的单例模式:Python 中的懒加载与自定义初始化参数 在软件开发中,单例模式(Singleton Pattern)是一种常见的设计模式,确保一个类仅有一个实例,并提供一个全局访问点。在多线程环境下,实现单例模式时需要注意线程安全问题,以防止多个线程同时创建实例,导致…...

DingDing机器人群消息推送

文章目录 1 新建机器人2 API文档说明3 代码编写 1 新建机器人 点击群设置 下滑到群管理的机器人&#xff0c;点击进入 添加机器人 选择自定义Webhook服务 点击添加 设置安全设置&#xff0c;详见说明文档 成功后&#xff0c;记录Webhook 2 API文档说明 点击设置说明 查看自…...

腾讯云V3签名

想要接入腾讯云的Api&#xff0c;必然先按其文档计算出所要求的签名。 之前也调用过腾讯云的接口&#xff0c;但总是卡在签名这一步&#xff0c;最后放弃选择SDK&#xff0c;这次终于自己代码实现。 可能腾讯云翻新了接口文档&#xff0c;现在阅读起来&#xff0c;清晰了很多&…...