【SDU Chart Team – Core】画布管理:维护画布 & 维护组件的生命周期

  • Post author:
  • Post category:其他




画布管理

画布按照画布层次结构进行对象的维护,详见之前的文章:

【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);
}



组件生命周期

那么进行了之前的详细解释后,组件的生命周期可以用如下状态机概括:

组件生命周期状态机



版权声明:本文为cyk0620原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。