思考GUI框架:FOP和OOP

DinS          Written on 2018/3/2

一个良好GUI程序的代码结构应该是什么样子的?
这是一个非常通用的问题,而且也非常重要。
本专题我们将探讨这个问题并尝试给出一个答案。

一、总体的思路

其实关于这个问题,前人早就有过思考,这就是MVC/MVP模式:

这是MVP的结构图。
先不管具体的内容,总体的思路是将界面显示层View和数据层Model分开,降低耦合。提供一个中间层Presenter负责调度,从而将一个GUI程序拆成3部分,各个部分通过接口沟通,各自可以独立变化。
这个思路可以说是GUI程序的黄金法则。

好处就不赘述了,有许多,这里集中探讨问题。
按照MVP的框架,P要去调用View的绘制展示接口,而View也要调用Presenter的接口来传递用户事件,这实际上是相互包含,not good。
并且随着View的呈现越来越复杂,绘制接口会越来越多,再加上多个视图,Presenter里的调用View对应接口会越来越复杂。
另外Presenter里实际上是业务逻辑存在处,随着业务的发展,业务逻辑也会越来越复杂。换言之,随着程序的发展,Presenter这个部分会越来越臃肿。

这确实是致命的问题,让我们尝试着解决这些问题。

二、一种面向过程的GUI代码框架

对于第一个相互包含的问题,细想一下GUI的绘制功能不应该暴露给Presenter,毕竟绘制是GUI自己的事,Presenter不应该管,降低耦合度。所以说GUI里含有Presenter的指针,但是Presenter对GUI一无所知,仅仅是接收一个消息,返回某些数据。GUI拿到这些数据后自行绘制。
对于第二个Presenter臃肿问题,可以考虑单独设立一个规则工厂,Presenter接收到用户事件后去规则工厂里找对应的规则,然后分别执行,再将执行结果返回。
基于这种改进思路,可以画出改良框架如下:

现在View与Presenter只依靠数据来沟通。
这里有一个问题:是不是所有的显示都可以只依靠数据?
可以这样想,计算机本质上都是整型和字符,所有看到的内容无非是通过这两大类以非常复杂的方式呈现的,所以还原到最根本上是可以的。
不过为了方便起见,我们可以在View里增加一个解释器ViewControler,负责将朴实的数据翻译成GUI控件内容,然后绘制,当然这是后话了。
并且提前透露一点,可以有一种万能数据结构简化数据的传递,稍后公布。

重点看Presenter部分新添加的内容。
什么是RuleFactory?规则工厂是业务逻辑存在的实体。
何谓业务逻辑?人进行了某个操作,然后我们期待着进行一系列动作,最后得到一个结果,这就是业务逻辑的本质表达。更具体而言,用户在GUI上进行了一个操作,这是一个UserEvent,根据业务要求,这个Event需要进行一系列EventActions,于是规则工厂就是根据UserEvent给出对应的EventActionList。
这样一来我们就把业务逻辑从Presenter里分离出来了,而且可以灵活变化。比如说可以用配置文件写出UserEvent到EventActions的映射,然后利用工厂模式实例化派生类,或者还可以使用脚本来解决,具体问题具体分析。

给出的EventAction是一个类体系,基类EventActionBase规定了4个接口。
为什么是这4个?这4个能够应付所有情况吗?先来理解什么是EventAction。
EventAction(EA)是业务中的原子操作,任何复杂的业务逻辑都是从各个EA按顺序拼装起来的。在最抽象的意义上讲,任何一个GUI的EA无外乎包含四个内容:提取数据->执行计算->更新数据->更新界面。
一个EA的内容可以是任意几个,但是不会超过这四个。
从计算机本质上看,我们提供一个输入,然后计算机执行,得到一个结果,并将结果显示出来供人查看。这是计算机能干的事情的抽象表达,这个过程分解出来就是这四个接口。也可以把EventAction视为微观尺度上的UseCase,这样可能更容易理解。
结论就是这四个接口足矣涵盖所有操作。输入和计算可以很复杂,但是毕竟只有这四个接口。

于是我们可以在概念上看看这个框架是如何运行的。
现在的GUI也都是框架,几乎所有都是事件驱动型的,一般都是On…()。
假设用户在界面上点击了一个按钮,名字是ButtonA,然后我们看看程序如何运转。
(1)GUI调Presenter的接口,将该UserEvent传给Presenter,比如说传递内容就是ButtonAOnClick。

(2)Presenter收到信息,然后调用RuleFactory的接口,将ButtonAOnClick传过去。

(3)RuleFactory收到信息,然后比如通过查表方式得知要进行EA1/EA2/EA3三个动作。

(4)RuleFactory使用工厂模式返回vector<EventActionBase*>,装载EA1/EA2/EA3。

(5)Presenter拿到EAList后,执行每个EA的4个接口(接口可能是空操作)。

(5+)执行EA过程中涉及跟Model的通讯,这是因为只有Presenter可以与Model通信,EA只能提出需要的数据WishList。

(6)全部执行完后,Model处于最新状态,并且Presenter里有一个要传递给View的数据集。

(7)GUI拿到数据集,自己根据数据集进行界面的绘制。

以上就是整个一个UserEvent的运行过程。

有了这个框架,以后维护程序就相当简单了。
如果是增加新功能,那么各个模块可以独立进行,没有瓜葛。
GUI做界面和显示,确信传过来的数据包含了所有信息。
RuleFactory更新注册信息。
EA派生出新的类执行新功能。
Model如果设计得好可以不用动,有新数据项的话EA会提出存储和索取请求。
而且每一部分的代码都很简洁,唯一可能是EA的Execute这部分会复杂,但是这个复杂是隔离开的,不会影响其他部分。

目前看起来一切都很好,不过在实现的过程中发现了几个问题:
第一,传递的data的实现形式问题。最好是有一个万能数据结构来装载,这个已经解决,那就是使用nlohmann大神的json来实现,用一个Messenger类包装起来。关于这部分探讨可见《nlohmann::json万能数据结构》。
第二,数据通信规则问题。如何确保与Model的通信是高效、全面的,这个涉及数据库设计,有些难度,仔细想想会很绕。
第三,将所有动作抽象为一个过程是否合理?这个是本质问题。如果仔细研究会发现,所谓的EA虽然看上去是OO的,但实际上就是一个函数,这也就是为什么我把这个框架称为面向过程的原因。
理论上来说,面向过程的方法可以解决问题,但是可能会引入不必要的麻烦。比如说如果我们的程序需要一个网络通信模块,这是一个对象,而且每次通讯取决于之前通讯的状态。
如果用这个框架来实现,我们在Execute里实例化网络模块,进行通讯,但是为了引入之前的状态,我们在输入数据里必须增加相关内容,并且执行完后需要在Model里存储相关信息。如果真是这样,麻烦先不说,网络模块本身的复杂度还会增加,因为其不能设计成记住自身的状态,而是要依靠外界输入来判定。
第四,维护并没有想象的那么容易。虽然各个模块可以独立变化、维护,但是要注意他们必须有一个统一的东西:数据项。这些数据项必须在写代码之前提前约定好,不然各个模块会六神无主。归根到底是数据库设计问题。

结论是这个框架适合纯面向过程的GUI,但是不能很好适应复杂变化和需求。
我们需要的是一个面向对象的GUI框架。

三、一种面向对象的GUI代码框架

再进入实际的代码之前,我们要先从思维上论证一下。
这里的前提条件是我们需要一个面向对象的GUI框架,换言之我们的程序本身已经是OO架构了。这点很重要:程序已经是OOP了,没有这一点很难往下推理。
既然是OOP,那么我们已经拥有了若干独立的对象,每个对象都能够完整地实现一个独立的功能,只通过对象的接口进行调用和交互。以上是一个良好的OOP程序应有的品质。
这样思考有什么意义呢?意义很大。
既然已经拥有若干良好的对象,那么GUI框架考虑的问题就很简单了:如何根据用户事件(UserEvent)调用若干对象相应的API,并且这个过程要实现灵活、解耦。
换言之如果GUI框架合理解决了用户事件和对象之间的映射关系,那么就算完成目标了。

我们依旧可以借鉴MVP的思路,不过对于Model要更新认识。
Model不再指数据中心,而是对象。这点非常关键,Model是对象。
如何组织这些对象达到我们的目的?可以制作一个Model基类,然后把Model的派生类作为wrapper包装现有的对象,并使用状态模式指定对象具体的API。最外层设立一个ModelManager管理所有Model类,完成灵活映射。
以上就是OO思路下的M。

中间的Presenter变化不大,依然充当枢纽,并配备规则工厂实现灵活映射。不过个人认为Presenter不能准确表达其意义,应该叫Hub更确切一些。

界面部分也还是之前的思路。

于是我们可以画出新的框架图:

大图点这里

详细讲解一下。
先看右侧的内容。
Object是已经有的类,可以是任意的。我们的目的是指定调用哪个类的哪个接口,为了做到这一点,我们需要把类包装起来,定义一个基类Model,只有两个接口:SetAPI和Execute。
也就是说指定一个API,然后调用该API。利用状态模式可以实现这个功能。
另外这个wrapper是全面的,因为实际的类也就是通过接口与外界交互,wrapper只不过增加了一层指定调用哪个接口,所以可以包装起任何的类。
有了各种wrapper以后,我们利用一个ModelManager装载所有wrapper,并在ModelManager构造时实例化所有wrapper,进而也就是实例化了实际的类。wrapper通过散列搭载,key就是实际类的名称。
于是通过ModelManager我们做到了给定类名和API名称,我们可以使用相同的代码调用对应类对应的接口。

再来看中间的Hub。
这个就简单了,充当了枢纽作用。接收View发过来的UserEvent,然后通过RuleFactory查表找到该动作触发后需要调用哪些类的哪些接口,然后调用ModelManager执行。这个映射可以放到配置文件里,写法跟写脚本类似。
特别注意RuleFactory在这个框架下的意义。业务逻辑现在不在RuleFactory里,因为按照OO原则,业务逻辑是封装在对象里面的。这里的RuleFactory充当的是GUI与非GUI部分的规则库,即一种映射关系。这样一来反而更加容易理解和实现,也更极容易维护。

最后看左边的View。
这是GUI部分提供的,指向同一个Hub。如果有用户事件,收集必要信息传给Hub,然后等待结果即可。
还有一点要注意,在这个框架下没有数据中心的概念,那么数据放到哪里了?
按照严格OO的原则,数据在对象里。也就是说一部分数据存在GUI控件里面,另一部分存在于最右边的对象里面。
于是我们在这个抽象层级就不需要考虑数据的问题,如果对象需要数据,View负责从GUI控件中提取数据发给Hub即可。如果数据正确,那么对象一定可以完整任务,就是这么简单。
不过GUI部分还有一些值得探讨的地方,后面再说。

让我们在脑海里运行一遍这个框架:

(1)初始化ModelManager,实例化所有非GUI类。

(2)用户在View1上发起UserEvent,收集数据并指定事件名称,比如ButtonAClick,然后发给Hub。

(3)Hub拿到信息,调RuleFactory接口,将事件名称ButtonAClick传过去。

(4)RuleFactory查表,发现该事件对应Ob1.Me1, Ob2.Me2, Ob1.Me3,提取出动作序列给Hub,纯字符串。

(5)Hub拿到动作序列,调用ModelManager的接口,针对每个动作指定对象和方法,然后执行。

(6)ModelManger内部找到合适的对象和方法,调用真正对象的方法。

(7)ModelManager执行完后结果返回Hub。

(8)Hub返回View1,View1根据结果更新GUI控件内容和状态。

我姑且将这个面向对象的GUI结构称为VsHMM。
但是这个框架到底有没有思维实验中的那么有效?
Talk is cheap. Show me the code。

见《VsHMM框架》。