profthecopyright / Thunder_Class

雷课堂大作业
GNU General Public License v3.0
179 stars 18 forks source link

GUI 侧接口需求分析 #22

Open cnDengyu opened 4 years ago

cnDengyu commented 4 years ago

前言

这个 issue 从 GUI 开发者的角度分析一下希望向内部发送的消息。

接口实现进度(可考虑动态更新)

程序开始

这里的程序开始指的是 QtGUIAdapter 开始执行构造函数。

创建 GUI 提供的 App 类的实例(Qt 上是 QApplication )。 调用基类 GUIAdapter 的伪构造函数。

登录

在内部的 TaskExecutor 构造时,可能会向 GUI 发送“打开登录窗口”消息

登录窗口,除了 Label 之外包含输入用户名和密码的输入框和一个登录按钮。(下文省略对 Label 的分析,并不再明确指出)

用户点击登录按钮时,需要发送 onLogin 消息。需要传递输入的用户名和密码。

管理员

内部处理 onLogin 消息后,可能会向 GUI 发送“打开管理员窗口”的消息,传入用户列表数据。

管理员窗口包含一个用户列表,以及相应的新增、删除、修改、保存的按钮。 进行新增、删除、修改时,由 GUI 暂存修改的数据。 当点击保存按钮时,希望发送 onUserListChange 消息给内部,并传入新的用户列表。

学生端

内部处理 onLogin 消息后,可能会向 GUI 发送“打开学生端窗口”的消息。

打开学生端窗口之前,应当有一个填入 ip 地址的引导窗口。 这里希望发送设置服务端地址消息,并传入填写的 ip 地址。 onSetServerIP()

学生端窗口包括:

  1. 一个视频显示控件。(可能用类似 Canvas 的控件,也可能利用 OpenGL )

  2. 一个开麦按钮和一个选择音频输入设备的下拉列表。 在这里希望发送: 打开麦克风的消息。 onStartAudioShare 关闭麦克风的消息 onCloseAudioShare 获取音频输入设备的消息,需要返回音频输入设备列表。 onRequireAudioInputDevices 选定音频输入设备的消息。 onSelectAudioInputDevice

  3. 一个静音按钮和一个选择音频输出设备的下拉列表。 在这里希望发送: 开关静音的消息 onSetMute(bool) 获取音频输出设备的消息,需要返回音频输出设备列表。 onRequireAudioOutputDevices 选定音频输出设备的消息。 onSelectAudioOutputDevice

  4. 学生端的答题控件(可考虑新开窗口实现,但可能造成窗口焦点变化) 在这里希望发送: 提交答案的消息,并传入作答数据。 onCommitAnswer

  5. 非控件消息 希望发送: 窗口焦点变化的消息 onFocusChange(bool)

教师端

内部处理 onLogin 消息后,可能会向 GUI 发送“打开教师端窗口”的消息。

教师端进入时应当直接开麦,退出时再关麦,开关麦的按钮不是刚需。

教师端窗口包括:

  1. 学生注意力显示控件

  2. 出题控件和发送题目的按钮 这里希望发送发起做题的消息。 onRaiseTest 以及结束答题的消息 onCloseTest

  3. 提问的控件,包括随机点名和指定回答 这里希望发送 bool onAskStudent(void) bool onAskStudent(Name) 传入点名数据,传回点名有效性 以及结束提问的消息 onCloseAskStudent()

  4. 麦克风管制按钮 这里希望发送 onMicControl(bool)

上面这些不一定要在同一窗口内。

程序运行

关闭窗口时,应当发送消息,让内部进行收尾工作 这里希望发送 onExit

程序开始时,不发送 onStart 之类的消息,因为已经有 onLogin 和 onSetServer

一个人想的难免有疏漏,还望广大家积极补充

23

profthecopyright commented 4 years ago

我的建议是,把一切用于模块间通信的“消息”都抽象为Message类的对象。对象中可以包含一个消息类型字段,配合定义各种表示消息类型的宏,类似于

// Message.h
定义消息类型
#define LOGIN_REQUEST 0
#define LOGOUT_REQUEST 1
#define OPEN_MIC_REQUEST 2

// 定义发送者和接收者(可选)
#define GUI 0
#define CORE 1
#define REMOTE 2

class Message
{
public:
    int type;
    int sender;  // 可选
    int receiver; // 可选

    void* additionalInformation; // 可选
};

然后在GUIAdaptor的各种onXXX()方法中,都可以打包一个Message类的对象,然后把这个Message作为参数传入对应TaskController的对象方法中。

virtual int TaskController::respondToGUIMessage(const Message& message) = 0

这个成员方法再具体实现对各种类型消息的解读和委派处理。

另外,这个Message类的消息框架还可以用于反向通信(InternalEvent,原InnerEvent)机制。只需要把InternalEvent类型的定义中包含一个Message类型的数据域即可。这样GUIAdaptor端响应核心类的事件也可以通过消息模式进行。这种方式有利于功能的改动和扩展,只需在Message.h中不断添加新消息类型的宏定义即可。

还有一个消息传递机制之前没有讨论过,就是从TCP端口接收到的远程传来的数据和发送的数据。这个数据指针可以包装在additionalInformation中(题目、图片、音视频),且发送端的逻辑可以是直接由TaskExecutor函数调用ConnectionBot(干事)方法从TCP端口发送数据。但接收端因为无法预知,也需要一个类似InternalEvent的事件监听机制,即接收端的ConnectionBot收到远程发来的数据后,需要把它包装成一个Event,__raise出来,由TaskController去响应(读取消息、解析、必要时再向上抛出事件通知GUI)。于是在TaskController接口定义中还需要包括响应该类消息的方法。

综上,程序模块间通信的各种消息都可以抽象成Message类的对象。层级关系(从上到下): GUIAdaptor -> TaskController -> ConnectionBot --- TCP 上级向下级传递消息,直接传Message对象作为函数参数,由下级处理。 下级向上级传递消息,将Message封装成Event,通过__raise抛出,由上级处理。 上级依赖于下级,下级不依赖于上级。

cnDengyu commented 4 years ago
  1. 把先前写出来的调用式消息传递改成 raise 确实更符合逻辑。但我还不太清楚这两种写法是否有本质区别。 只要有双向消息传递,就要手动避免无限消息循环。保留指针进行调用式发送消息可能导致调用网复杂, raise 方式发送消息也可能导致消息网复杂,这是逻辑层面固有的复杂度,在代码实现上无法避免。

  2. 在原生win32API中,消息传递是通过一个 Wndproc 的 message 参数传递的,而在 MFC 中封装成了许许多多 onXX() 函数,开发者不用管 Message 解析的具体事宜。 在 Android , ios , Qt , unity 等等开发平台上都采用了类似 MFC 的模式。 所以说所有消息封装进 Message 再手动解析是否更优,我个人持保留态度。 另外,我的思路是一个消息就是一个模块(XXBot),分头开发,无必要在此之上加一层消息解析。

  3. 至于TCP,可以按上面说的来。

profthecopyright commented 4 years ago
  1. OOP是设计层面的、方便人类理解的,软件设计范式(如避免设计层面的类循环依赖)也是尽力避免人类出错的;从机器层面讲一切都没有本质区别。raise的本质也是回调函数/指针挂钩,在调用由关键字event修饰的函数自动调用其handler函数。唯一的好处在于写代码的时候不需要显式产生相互依赖,只需要单向注册函数挂钩。

  2. 把所有消息封装成Message的抽象方式意义在于(1)把GUI的事件和网络端口的事件抽象统一起来,(2)减少Bot/Agent的个数,(3)减少接口声明中需要定义函数的数量,提高可扩展性。GUIAdaptor和各个图形控件的联系仍然是原来的onXXX模式,但只是在onXXX函数体中统一封装为Message,这样GUIAdaptor根本不需要管TaskController如何调用不同的Bot/Agent实现,分离更加彻底。GUI控件端用onXXX模式封装是因为开发者更习惯于想像具体的GUI事件(如鼠标单击按钮)等等,所以一个事件对应一个名字的onXXX函数;但在TaskController角度,处理的本来就是个抽象事件(如登录请求),而且不排除开发中经常变动实现模式/修改调整函数名称,所以我倾向于减少模块间接口的函数数量。

还有就是,从core的角度来看不同Bot之间其实无法做到彻底的分离,往往需要core中的不同模块相互配合才能真正完成一项功能,而且不同的功能可能共用很多函数。比如用户只点了一个按钮,要传输一张屏幕截图,那么我们在core中需要先把它进行压缩,然后再序列化,最后打开端口传送;如果用户只点了一个按钮,要发送一道题目,那么core中需要把对应的字符串直接序列化,并打开端口传送;如果core收到了一个远程来的题目数据想要显示,那么需要把它还原为我们定义的题目格式,然后扔一个消息给GUI。以上三种功能如果直接分拆到三个Bot里去做,一个功能一个Bot,那么这三个Bot里必定会共用很多代码(网络传输、序列化等等),然而这三个Bot从高层看还没有任何关系,我们如果不想同样的代码粘贴三次,就又得把共用的那部分代码提炼出来封装成类,那么这三个Bot的分离就失去了意义(从本质上讲不同的Bot其实还是不同的Message,但接口变得复杂了很多),还降低了简明性和可维护性。因此我倾向于在core中按照可互相分离的本质功能而非用户功能分拆,每个Bot/Agent代表一个独立的本质功能,然后TaskExecutor实现的就是把GUIAdaptor传来的用户功能请求转换为本质功能序列的调度逻辑。

在我新的类图中,每个Agent对应的都是一种/一类本质功能。 LoginAgent:登录验证和数据库维护。 ServerLogAgent:服务器日志(这也是Message机制的一个好处,可以在日志中统一记录各种事件)。 DataAgent:数据格式转换(包含原始的bitmap图像/波形音频/字符串向量单选题的Data对象压缩并序列化为可由TCP端口传输的字节流,及其逆过程)。 ConnectionAgent:数据发送与接收(可能包含多线程管理)。

这四个Agent基本上覆盖了所有的十几种用户功能,而且不会有大片重复的代码。

此外另一个考虑是,无论如何我们在向上传递和网络传输的过程中要用于事件/消息机制,这部分Message定义是在所难免的。既然多定义了这些东西,那最好把它们充分利用起来以减少整体的复杂程度,让设计逻辑更简洁一些。

  1. 关于消息循环。从上级到下级的消息传递一直是通过函数调用的机制,而下级到上级是__raise机制。这两种机制是完全独立的两套处理函数,而且并不需要报告反馈。因此所有的消息都只会出现一次并处理一次,我认为一般不会出现循环问题。