编辑器系统设计

老架构的缺点

  • 按照最小的基本类型进行数据模型的设计,粒度太细,耦合太重
    比如:一个整型、一个浮点型,作为一个数据模型。当UI界面的数据变化时,上层通过调用数据模型提供的setvalue等接口,直接改变底层这个数据模型的数据。数据操作层和数据模型层耦合也比较严重。
    解决:
    把数据封装一层attribute层,一个属性包含一种定义内的数据集合,不再是按照基本类型划分。比如坐标,就是一个(x, y)的组合,比如资源类型,就是一个完整的类类型表示。
    对于耦合重的问题,把整个框架划分成4层,分别为UI层、执行层、数据访问层和数据模型层。各层独立干活,通过对外的接口分离。
  • 各个模块对于感兴趣的数据变化,需要让上层主动通过QT的connect来绑定,这种端到端的连接,比较繁琐和复杂。
    编辑器系统设计_第1张图片
    比如上图的,一个Condition类表示一个UI的某种操作下的数据模型。当前的做法,需要把x/y跟与之相关联的的UI面板一一手动链接起来。当参数面板中的UI操作时,会改变x的值,那么这样连接后,便会影响到其他UI或者数据模型。
    解决:
    通过通知监听系统实现。
  • 各种效果添加的动作,散落在代码各处,接入新效果会导致接入困难和花费较长时间。
    解决:
    实现一个插件系统,将每个效果作为一个独立的插件来使用。
  • 使用QWidget作为UI框架,开发难度较大。要匹配设计的UI稿,往往微调就需要花费较长时间。另外,对于一个效果作为一个插件来说,事件面板中的各种子控件就不太好配置。
    解决:
    采用QML改写参数面板,把事件面板中的子控件改为一个个独立的QML文件,供配置。提供一个JSON配置文件,来描述事件面板的layout。这样在配合插件系统的情况下,就可以把这个JSON配置文件作为效果插件的一部分提供。
    并且,可以非常方便的应对后期针对UI上的变化等需求,而不需要花费太多的功夫。
  • 老架构无法支撑撤销恢复功能
    由于代码松散,没有把许多的操作和数据隔离的清楚。实现撤销恢复的难度比较大。
    解决:
    根据分层的模型,以及对数据访问的控制等操作,把所有的一个个对数据的操作独立开来,作为一个个的命令,执行动作的时候就调用这个命令的exec接口,而撤销恢复的时候相应的调用其undo/redo接口。
    这三个接口具体的实现,就交由具体的命令来完成。

新架构

通知监听系统

通知中心Notifier

Project::m_notifier管理了所有的Listener,即所有设置的Listener都加入到了Project::m_notifier数组中,在每个槽函数中完成数据的遍历操作。
即通知中心在一个工程中只有一个,用于管理所有的监听者。

添加Listener

  • 新建Listener时,构造时传入通知中心的制作Project::m_notifier
  • 或者主动调用SetNotifier时传入Project::m_notifier

监听者Listener

所有需要监听来自通知中心发送的事件的对象,可以继承自Listener,重写对应的虚函数,即可。
接口分为两类:

  • 一类大而全的接口
    virtual void OnBeginEdit(bool isUndoRedo) {}
    virtual void OnAfterEndEdit(const ChangeDataCtn &changeDataCtn, bool undoRedo) {}
    virtual void OnBeginExecAtomCmd() {}
    virtual void OnAfterEndExecAtomCmd(const ChangeDataCtn &changeDataCtn) {}

这类接口通过CommandCtrl控制,一般是在命令执行前后发送。

  • 一类具体的接口
    主动调用的接口。
    DECLARE_LISTEN_FUNC(OnAfterEffectInserted)
    DECLARE_LISTEN_FUNC(OnAfterEffectRemoved)

这类接口需要继承Listener的子类,实现相应的接口完成。

通知的发送

在通知中心的构造函数中,通过使用QT的信号槽机制,将对应的命令控制中心的信号槽连接起来。
编辑器系统设计_第2张图片
信号由命令控制中心发送,连接到对应的槽。
也可以主动发送通知:
编辑器系统设计_第3张图片
此处主动调用通知中心的接口OnAfterEventNodeEdited,而内部会调用到Listen的同名接口中,如果有继承自Listen的子类重写了这个接口,那么就能收到和处理这类消息。

原子命令系统

原子命令基类

···
// 执行
virtual void Exec() = 0;
// 撤销
virtual void Undo() = 0;
// 恢复
virtual void Redo() = 0;
···

执行原子命令

void DataAccessBase::ExecAtomCommand(AtomCommand::ptr atomCommand)

执行原子命令,只有三步:

  • flag++,emit SigExecBegin
  • 执行原子命令的Exec接口,完成实际的操作
  • flag–,emit SigExecOver

执行undo撤销时,

  • emit SigUndoRedoBegin
  • 从undo栈中取一个命令,然后执行这个命令的Undo接口,直至栈空
  • emit SigUndoRedoOver

执行redo恢复时,

  • emit SigUndoRedoBegin
  • 从redo栈中取一个命令,然后执行这个命令的Redo接口,直至栈空
  • emit SigUndoRedoOver

命令组

命令组里存放一次undo/redo的所有命令,用一个vector存放。命令组的作用是为了在像滑杆这种控件拖动时会产生大量的命令,将他们打包到一个命令组中,而每次的命令,依旧会去执行底层数据模型的更新(这种更新是有必要的,有时需要把这个滑杆的数据变化体现在另外的UI界面上),在撤销恢复时,依旧保证一次恢复到原始位置。

命令的合并

merge,在把命令加入到命令组中前,判断这条命令的类型是不是自己想要的类型。因为,每条命令都是由一个继承AtomCommand原子命令的子类,那么merge的实现也是在子类中实现,那么就可以在merge的时候判断传入的命令类型是否是自己即可。
是的话就插入到命令组中,并把之前的命令出栈。

bool LiquifyDeformDragCmd::Merge(AtomCommand *other)
{
    auto dragCmd = dynamic_cast(other);
    if (!dragCmd) {
        return false;
    }
    m_curValue = dragCmd->m_curValue;
    return true;
}

分层

数据模型层,数据访问层,执行层,UI层

数据模型层

对最终的数据进行存储和操作的层。在代码中使用Private后缀的类,比如EffectModelPrivate。数据模型层只可以通过数据访问层进行访问,与其他层次完全隔离。

数据访问层

针对每个数据模型定义一系列的操作接口,接口中负责对数据模型进行操作,并且记录原子命令,收集操作日志,与数据模型强绑定。
所有外部需要更改数据,只能通过数据访问层接口去操作。

执行层

执行层抽象为一个个单独的执行器,一个功能对应于一个执行器,功能之间彼此互不干扰。执行层对底层使用数据访问层的接口对数据模型进行操作,对上层则使用key与工厂的方式实现与UI的解耦合。

UI层

UI层采用QWidget + QML的方式组织工程。工程的主体框架使用QDockWidget以实现自定义布局,每一个Dock内则使用QQuickWidget嵌入QML窗体。

操作

编辑器系统设计_第4张图片

  • 执行层executor只是一个包装的壳
  • 针对1/2/3/4的增删操作,每一个动作将作为一个executor。
  • 如果移动的操作,是先删除后增加,那么整个移动的操作将作为一个executor,而它里面包含两个executor,一个删除,一个增加的操作。而这两个操作会被beginLog/endLog包裹,而形成一条命令。

插件系统

插件管理器

EffectPluginManager

使用一个全局单实例,来作为插件管理器。

插件注册宏

#define REGISTER_EFFECT_PLUGIN(type, classname) \
    inline bool s_bRegiste##classname =         \
        EffectPluginManager::Instance()->InsertEffectPlugin(type, new classname());

注册时传入一个type类型,和一个具体插件的类名。
注意:

#表示:对应变量字符串化  
##表示:把宏参数名与宏定义代码序列中的标识符连接在一起,形成一个新的标识符
连接符#@:它将单字符标记符变换为单字符,即加单引号。例如:
#define B(x) #@x 则B(a)即'a',B(1)即'1',但B(abc)却不甚有效

插件接口

定义所有具体插件需要实现的接口。

EffectPluginInterface

基本上接口中定义的都是纯虚函数,里面的接口需要具体的插件类去选择实现之。
注意:

满足下面条件的 C++ 类成为接口:
	类中没有定义任何变量
	所有的成员函数都是纯虚函数,且是公有的

所有的插件都需要直接或者间接的继承自插件接口类EffectPluginInterface

参数面板设计

目的:让新添加效果的时候更简单,只需要简单的配置JSON配置文件(供给非开发人员使用)
配合插件系统,让面板的layout成为插件的一部分,由插件掌控,后续方便更新,也方便一旦UI稿变化后,只需要小动JSON配置文件即可。
方法:

  • 将大部分的基础组件,封装一层。设置好默认的宽高、颜色、字体等默认配置
  • 将QML的所有配置统一在一个地方,包括一些图片资源,方便后续换肤等操作
  • 在基础组件的基础上,根据业务功能再封装一层组件,这层组件可以直接供配置文件配置使用

QML架构设计

编辑器系统设计_第5张图片
ListView2里的Loader加载的独立的QML文件,采用模板方法,抽取一个公共的基类GroupItem和基本接口,具体的子类根据需要去实现相应的接口,完成流程上的规范处理。
当前基类中规定了对于更新model、更新UI布局(只需要首次才需要)、及更UI数据(每次刷新都需要更新)的规定:

function updateAttributeValue(varListModel) {
    itemListModel = varListModel

    updateWidgetInfo()
    updateWidgetValue()
}

参数面板流程示意

编辑器系统设计_第6张图片

撤销恢复

调用栈:

InspectorEventModel::TermAdd()
EventAddTermExecutor::Exec()
EventSelectExecutor::Exec()
Executor::EndLog()
CommandCtrl::EndLog
m_undoStack.push  // 如撤销栈

首先创建一个全局唯一的命令控制中心:CommandCtrl对象

执行exec

创建一个原子命令对象EffectSetAttrCmd,EffectSetAttrCmd继承自AtomCommand,并实现执行、撤销、恢复接口。class EffectSetAttrCmd : public AtomCommand

auto command = std::make_shared(m_owner, GetUniqueKey(), key, value);
ExecAtomCommand(command);

执行原子命令,即调用命令控制中心的执行接口Exec

void DataAccessBase::ExecAtomCommand(AtomCommand::ptr atomCommand)
{
	m_commandCtrl->Exec(atomCommand);
}

void CommandCtrl::Exec(AtomCommand::ptr command),完成:

  • 1、命令的执行(最终调用到EffectSetAttrCmd原子命令的执行接口)
  • 2、记录原子命令道队列中m_atomCmdQueue
  • 3、如有必要可以在执行命令前后增加信号,通知外部相关动作的执行

撤销

问题记录

  • 如何实现可折叠的带圆角的矩形?即下面这个:
    编辑器系统设计_第7张图片
    折叠后
    解决办法:还是一个rectangle(内部包含一个GroupHeader+ListView2),设置这个rectangle的圆角,并将rectangle的高度和GroupHeader+ListView2绑定起来。在折叠时,将ListView2的高度设置为0,还原时还原ListView2的高度。

你可能感兴趣的:(项目,编辑器,ui)