画布管理
画布按照画布层次结构进行对象的维护,详见之前的文章:
【SDU Chart Team – Core】画布层次结构
。这篇文章主要讨论的是后端中如何进行画布管理:
画布上下文
上下文即一系列被维护的对象。其中主要包括:
画布
的
SVG接口
、二级层次中
预定义域
和
组件图形域
的
SVG接口
(前端域交给前端维护)、
组件的集合
(“后端Id-组件对象”映射关系)、
预定义对象的集合
(“SVG接口哈希-SVG接口”映射关系)、
暂存区
、
画布范围
。
-
画布的SVG接口
画布,即绘图区域,也是画布层次结构中的<svg>元素对象。画布是个动态变化的区域,通过
viewBox
,
width
,
height
进行动态更新。画布可由
viewBox
支持负坐标。注意将画布和视角进行区分。视角是一个前端维护的用户视图,是利用前端滚动条机制实现的。画布是一个前后端统一的概念,包括了坐标系的统一和功能的统一。
-
预定义域的SVG接口
二级层次中的<defs>元素对象。其中包含了所有预渲染内容,例如<marker>,<gradient>等,由后端中的组件给出,并在组件增删过程中动态维护。
-
组件图形域的SVG接口
二级层次中的维护组件图形的<g>元素对象。包含了所有的组件图形元素。按照画布层级结构,每个组件图形元素也是以<g>为根的图形元素集,并在组件图形域中以列表形式排列。
-
组件集合
一个“后端Id-组件对象”的映射关系。一方面是便于后端查找组件,另一方面是对组件的shared_ptr进行持久化,满足对组件的持续维护。
-
预定义对象集合
一个“SVG接口哈希-SVG接口”的映射关系。该映射便于在组件增删时对预定义域的SVG接口进行更新。
-
暂存区
一个有序表,存储了暂时被删除的组件的shared_ptr。用于撤销操作。
-
画布范围
画布范围与<svg>元素对象中的
viewBox
,
width
,
height
属性绑定。其中包含四个字段:
x
,
y
,
width
,
height
。画布范围基于关键点进行更新。
组件的增加
组件的创建
组件采用组件工厂进行创建。组件工厂使用简单工厂设计模式,其直接使用字符串到
std::function
的映射创建抽象组件:
// 生产方法
std::function<std::shared_ptr<ComponentAbstract>()> newRectangle = [](){
return std::dynamic_pointer_cast<ComponentAbstract>(std::make_shared<Rectangle>());
};
// 生产映射
std::unordered_map<std::string, std::function<std::shared_ptr<ComponentAbstract>()>> ComponentFactory::newComponent = {
{ "rectangle", newRectangle },
{ "line", newLine },
{ "isometric_cube", newIsometricCube },
//...
};
// 生产
std::shared_ptr<ComponentAbstract> ComponentFactory::createComponent(const std::string &type) {
return newComponent[type]();
}
这个映射由后端维护。
更新上下文
如果组件被成功创建,我们就获得了一个组件的指针,那么接下来就是将组件添加到画布的上下文中。我们需要更新:
组件集合
和
组件图形域SVG接口
、
预定义集合
和
预定义域SVG接口
。
// 用添加的组件更新上下文
void Canvas::_component_added(const std::shared_ptr<ComponentAbstract> &comp) {
components[comp->getId()] = comp; // 将组件加入当前组件集合
if (auto el = comp->getSVGI().lock()) { // 将组件维护的SVGI加入DOM树
if (removed_layer.count(el->hash())) { // 加入特定层级
els->add(el, removed_layer[el->hash()]);
removed_layer.erase(el->hash());
} else els->add(el); // 加入到末尾
}
for (auto &_def : comp->getDefs()) { // 维护Defs表
if (auto def = _def.lock()) {
Lewzen::HASH_CODE hash = def->hash();
if (!defs_counter.count(hash)) defs->add(def);
defs_counter[hash]++;
}
}
}
初始化&事件响应
当然,被创建的组件对象也需要对自己被创建这一事件进行响应,故
完整
的添加组件的方法实现如下:
// 添加组件
std::shared_ptr<ComponentAbstract> Canvas::add(const std::string &type) {
auto comp = ComponentFactory::createComponent(type); // 创建一个组件
comp->init(); // 初始化组件
_component_added(comp); // 加入组件
comp->onAdded(); // 添加事件
return comp;
}
组件的选中
这是一个命令行携带的功能,也是命令行在本项目中产生的一个基本概念。根据局部性,多个连续命令作用于同一个组件或同一组组件的概率很大。
为了节省命令中对组件Id的重复引用
,提出了
游标
的概念。
单游标和多游标
-
单游标
使用
cursor <id>
令游标指向一个组件。此后若有命令支持单组件的输入,则使用游标对应的组件作为输入。例如在画布管理中,拷贝和删除是是支持单组件的。
-
多游标
使用
cursors [<id>s]
令游标指向多个组件。此后若有命令支持多组件的输入,则使用游标对应的组件作为输入。例如在画布管理中,拷贝和删除是是支持多组件的。
两者在实现上是互斥的(单游标比多游标开销更低),在概念上是包含的(单游标是多游标的特例)。
游标的获取
游标就是组件指针,它/它们来源于画布的上下文,故画布管理中提供游标的获取方法:
// 指向组件
std::weak_ptr<ComponentAbstract> Canvas::cursor(const std::string &id) {
if (!components.count(id)) return std::shared_ptr<ComponentAbstract>(nullptr);
return components[id];
}
// 指向多个组件
std::vector<std::weak_ptr<ComponentAbstract>> Canvas::cursors(const std::vector<std::string> &ids) {
auto vec = std::vector<std::weak_ptr<ComponentAbstract>>();
for (auto &id : ids) if (components.count(id)) vec.push_back(components[id]);
return std::move(vec);
}
游标切换器
考虑所有情况,有的操作仅支持单组件,有的操作仅支持多组件,有的操作同时支持但实现逻辑不同。为了方便对这些情况分别实现,同时将命令行游标进行封装以达到保护隔离的效果,定义并实现了一些Switch方法:
-
仅支持单游标
void Register::switchCursorOnly(json &j, const std::string &module_type, std::function<void(std::shared_ptr<ComponentAbstract> &)> doSomething) { if (!selected) { // 未选中 j["status"] = NTARGET; return; } auto c = selected_with(module_type); if (!c) { // 检查注册 j["status"] = NULLREG; return; } doSomething(c); }
-
仅支持多游标
void Register::switchMultiCursorOnly(json &j, const std::string &module_type, std::function<void(std::vector<std::shared_ptr<ComponentAbstract>> &)> doSomething) { if (!multi_selected) { // 未选中 j["status"] = NTARGET; return; } auto vec = multi_selected_with(module_type); if (vec.size() == 0) { // 检查注册 j["status"] = NULLREG; return; } doSomething(vec); }
-
单游标和多游标分别处理
void Register::switchBoth(json &j, const std::string &module_type, std::function<void(std::shared_ptr<ComponentAbstract> &)> doSomethingA, std::function<void(std::vector<std::shared_ptr<ComponentAbstract>> &)> doSomethingB) { if (selected) { // 选中单个 if (!selected) { // 未选中 j["status"] = NTARGET; return; } auto c = selected_with(module_type); if (!c) { // 检查注册 j["status"] = NULLREG; return; } doSomethingA(c); } else if (multi_selected) { // 选中多个 if (!multi_selected) { // 未选中 j["status"] = NTARGET; return; } auto vec = multi_selected_with(module_type); if (vec.size() == 0) { // 检查注册 j["status"] = NULLREG; return; } doSomethingB(vec); } else j["status"] = NTARGET; // 未选中 }
组件的删除
依托组件的选中机制,尽管删除分为但各组件的删除和多个组件的删除,但可进行等同处理。
更新上下文
删除一个组件时,我们也需要更新:
组件集合
和
组件图形域SVG接口
、
预定义集合
和
预定义域SVG接口
。
// 用删除的组件更新上下文
void Canvas::_component_removed(const std::shared_ptr<ComponentAbstract> &comp) {
components.erase(comp->getId()); // 将组件从当前组件集合移除
if (auto el = comp->getSVGI().lock()) { // 将组件维护的SVGI从DOM树移除
removed_layer[el->hash()] = get_idx_in_els(comp->getId()); // 记录层级
els->remove(el); // 移除
}
for (auto &_def : comp->getDefs()) { // 维护Defs表
if (auto def = _def.lock()) {
Lewzen::HASH_CODE hash = def->hash();
if (defs_counter.count(hash)) defs_counter[hash]--;
if (defs_counter[hash] == 0) defs_counter.erase(hash);
}
}
}
加入暂存区&事件响应
组件的删除并非直接结束生命,而是进入暂存区,并记录其时间。接下来组件需要响应被删除事件。完整的删除实现如下:
// 暂时移除组件
std::vector<std::weak_ptr<ComponentAbstract>> Canvas::remove(const int &time) {
std::vector<std::weak_ptr<ComponentAbstract>> vec; json j;
Register::switchBoth(j, "", // 跳过注册检查
[&](std::shared_ptr<ComponentAbstract> &comp){
_component_removed(comp); // 移除组件
removed.insert({time, comp}); // 将组件移动到暂存区
comp->onRemoved(); // 移除事件
vec.push_back(comp);
},
[&](std::vector<std::shared_ptr<ComponentAbstract>> &comps){
for (auto comp : ComponentAbstract::extractTop(comps)) {
_component_removed(comp); // 移除组件
removed.insert({time, comp}); // 将组件移动到暂存区
comp->onRemoved(); // 移除事件
vec.push_back(comp);
}
});
return vec;
}
组件的撤销删除 & 暂存区维护
撤销删除 (重添加)
对于撤销删除组件的操作,一种解决方法是重新添加组件(类似加载),这种方式开销太大。使用暂存区,将组件暂时移出被渲染的上下文,但不将组件彻底清除;等到撤销删除时,重新加入上下文,这样就只是指针移动的开销。
重添加的完整实现如下:
// 重新增加组件
std::vector<std::weak_ptr<ComponentAbstract>> Canvas::readd() {
if (removed.size() == 0) return {};
int time = removed.rbegin()->first; // 获取时机
std::vector<std::weak_ptr<ComponentAbstract>> vec;
auto bound = removed.lower_bound(time);
for (auto it = removed.rbegin(); it->first >= bound->first && it != removed.rend(); it++) {
auto comp = it->second; // 取出组件
_component_added(comp); // 加入组件
comp->onReadded(); // 重添加事件
vec.push_back(comp);
}
removed.erase(bound, removed.end());
return vec;
}
获取暂存区中最新被删除的组件,然后重添加至上下文。
回收暂存区 (丢弃)
暂存区并非一直添加组件被删除的组件,那样会造成内存泄漏。注意到在重做操作中,有一种情况会导致结果无法重做:撤销后采取了新的操作。
那么在这种情况下,假设
t
t
t
刻进行了删除,然后撤销回滚至
x
x
x
刻;此时进行了新的操作,那么显然
t
t
t
刻的删除无法被撤销了。事实上,
∀
t
>
x
\forall t > x
∀
t
>
x
的删除都无法被撤销,因此
t
>
x
t>x
t
>
x
刻被删除的组件都可被回收。
丢弃的完整实现如下:
// 丢弃组件
bool Canvas::discard(const int &time) {
auto bound = removed.lower_bound(time);
for (auto it = removed.rbegin(); it->first >= bound->first && it != removed.rend(); it++) {
it->second->onDiscarded(); // 丢弃事件
}
removed.erase(bound, removed.end());
return true;
}
即丢掉
time
\text{time}
time
后被加入暂存区的所有组件。丢弃后,没有任何一个共享指针指向该组件对象,组件对象因此被自动回收。
组件的拷贝
拷贝事实上是调用了组件的
拷贝方法
,拷贝方法需要在之后的文章中进一步解释。拷贝在画布管理中的实现如下:
// 复制组件
std::vector<std::weak_ptr<ComponentAbstract>> Canvas::copy() {
std::vector<std::weak_ptr<ComponentAbstract>> vec; json j;
Register::switchBoth(j, "", // 跳过注册检查
[&](std::shared_ptr<ComponentAbstract> &comp){
auto comp_c = comp->clone(); // 拷贝组件
vec.push_back(comp_c);
},
[&](std::vector<std::shared_ptr<ComponentAbstract>> &comps){
for (auto comp : ComponentAbstract::extractTop(comps)) {
auto comp_c = comp->clone(); // 拷贝组件
vec.push_back(comp_c);
}
});
return vec;
}
组件的层级变化
对
组件图形域
的
SVG接口
中各组件的顺序进行更新。
获取组件的层级
即获取组件的SVG接口在组件图形域的SVG接口的子元素表中的索引:
int Canvas::get_idx_in_els(const std::string &id) {
for (int i = 0; i < els->children().size(); i++) {
if (els->child(i)->Id.get() == id) {
return i;
}
}
return -1;
}
向前、向后、最前、最后
-
向前
// 向前 bool Canvas::forward(const std::string &id) { if (!components.count(id)) return false; int idx = get_idx_in_els(id); if (idx == -1) return false; if (idx == els->children().size() - 1) return true; // 已经是最前 if (auto el = components[id]->getSVGI().lock()) { els->remove(el); // 首先移除 els->add(el, idx + 1);// 再加入到指定位置 } }
其他三个操作类似,只是部分位置变动:
-
向后
if (idx == 0) return true; //... els->add(el, idx - 1);
-
最前
els->add(el);
-
最后
els->add(el, 0);
组件的序列化和反序列化
为了保存和加载工程,组件可被序列化和反序列化。在这里针对画布讨论的是所有组件的序列化和反序列化,针对单个组件(的子树)的序列化和反序列化请看后面的文章。
序列化
为方便表示,序列化最终的产出是Json。序列化中需要保存组件的一系列关系,而涉及画布管理的部分则是层级关系。其大致过程为按任意顺序对所有(父子关系中的)顶级组件的按照父子关系进行Dfs,然后序列化出组件树,接着产出为序列化的组件森林;最后记录组件的层级顺序,添加到Json结尾。
序列化的具体实现:
// 序列化
const json Canvas::serialize() {
std::vector<std::shared_ptr<ComponentAbstract>> comps;
std::vector<std::string> processed;
for (auto p : components) comps.push_back(p.second);
comps = ComponentAbstract::extractTop(comps); // 记录顶级组件
json jl;
for (auto comp : comps) {
json jc;
comp->serialize(jc, processed);
jl.push_back(jc);
}
json idxs;
for (auto &id : processed) { // 记录层级
idxs.push_back(get_idx_in_els(id));
}
json j;
j["indices"] = idxs;
j["comps"] = jl;
return j;
}
反序列化
序列化的逆过程,对序列化得到的Json进行反解析:对所有顶级组件进行反序列化,得到组件森林,然后按照被记录的组件层级顺序对这些组件重排序。
反序列化的具体实现:
void Canvas::deserialize(const json &j) {
std::vector<std::string> processed;
for (auto &jc : j["comps"]) {
ComponentAbstract::deserialize(jc);
}
std::vector<int> idxs = j["indices"]; // 获取层级
std::vector<std::shared_ptr<Lewzen::SVGIElement>> n_els = std::vector<std::shared_ptr<Lewzen::SVGIElement>>(idxs.size());
auto o_els = els->children();
for (int i = 0; i < idxs.size(); i++) n_els[idxs[i]] = o_els[i]; // 重排序
els->children(n_els);
}
画布范围更新
每次对组件进行更新时,画布的范围应当自动调整。以A4为单位大小为例,则画布应当是能始终容纳所有组件的最小A4的倍数的矩形区域。
因此首先需要维护所有组件的外接矩形区域。这里进行了简化,采用的是关键点集合进行维护。为了优化时间复杂度,使用的是可删除堆。(底层数据结构是是映射和平衡树;STL中即map和multiset)
// 可删除堆
Canvas::CPHeap Canvas::infX = CPHeap("<");
Canvas::CPHeap Canvas::infY = CPHeap("<");
Canvas::CPHeap Canvas::supX = CPHeap(">");
Canvas::CPHeap Canvas::supY = CPHeap(">");
// 更新堆
for (auto &p : comp->getCorePoints()) { // 更新点集
if (auto pp = p.lock()) {
auto cpid = comp->getId() + ":" + pp->getId(); // 生成点id
infX.remove(cpid), infY.remove(cpid), supX.remove(cpid), supY.remove(cpid); // 移除旧的
if (is_updating) { // 增加新的
auto canvas_point = (*pp)(Lewzen::CanvasCoordinateSystem::canvas_coordinate_system); // 使用画布坐标系
infX.add(cpid, canvas_point.get_x()), infY.add(cpid, canvas_point.get_y());
supX.add(cpid, canvas_point.get_x()), supY.add(cpid, canvas_point.get_y());
}
}
}
最终的画布范围就是对堆顶进行查询,然后进行取整:
void Canvas::setCanvasTranslate() {
// 获取上下界
int lowerX = std::floor(infX.top()), upperX = std::ceil(supX.top());
int lowerY = std::floor(infY.top()), upperY = std::ceil(supY.top());
lowerX = lowerX-((lowerX%A4WIDTH)+A4WIDTH)%A4WIDTH;
upperX = upperX+(A4WIDTH-(upperX%A4HEIGHT))%A4HEIGHT;
lowerY = lowerY-((lowerY%A4HEIGHT)+A4HEIGHT)%A4HEIGHT;
upperY = upperY+(A4HEIGHT-(upperY%A4HEIGHT))%A4HEIGHT;
// 更新viewBox
x = lowerX, y = lowerY;
width = std::max(upperX - lowerX, A4WIDTH), height = std::max(upperY - lowerY, A4HEIGHT);
}
组件生命周期
那么进行了之前的详细解释后,组件的生命周期可以用如下状态机概括: