『重构--改善既有代码的设计』读书笔记---Duplicate Observed Data
当MVC出现的时候,极大的推动了Model与View分离的潮流。然而对于一些已存在的老系统或者没有维护好的系统,你都会看到当前存在大把的巨大类----将Model,View,Controller都写在了一个widget中。一个分层良好的系统,应该将处理用户界面和处理业务逻辑的代码分开。原因如下
- 如果你此时需要用不同的用户界面来展示数据,比如微软Excel中的饼状图和折线图,他其实内部展示的数据是一样的,但如果你把这两层用户界面逻辑都放在一个widget中去的话,你就会让这个wiget变得复杂无比,因为他同时承担了两个责任,一个是“饼状图”一个是“折线图”。
- 当你让Model与GUi分离之后,你可以让他们两个之间的维护和演化变得更加容易,你甚至可以让不同的开发者进行分别的开发。
分离之中最困难的就是数据的分离,因为你可以很轻松的把行为划分到不同部位,但数据却没这么容易。因为你需要考虑它的同步问题,举个例子,如果你此时的GUI空间需要显示你Model中的name放到一个单独的label中去,那么你可能需要内嵌于GUI的同时,也需要在Model中也保存一份。自从MVC出现之后,用户界面框架都使用多层系统来提供某种机制,使得你不但可以提供这类数据,并保持它们同步。
如果你遇到的代码不像上面所讲的单层方式,而是两层方式开发--业务被内嵌于用户界面之中,你就有必要将行为分离出来。行为分离主要的工作就是函数的分解和搬移,但数据就不同了,你不能仅仅只是移动数据,你必须将他复制到新的对象之中,并提供相应的同步机制。
做法:
- 修改View类,使其成为Model类的观察者(Observer),如果没有Model类就建立一个,如果没有从View到Model的关联,就将Model作为View的一个字段存入。
- 针对GUI中的Model数据,使用Self Encapsulate Field。
- 编译,测试。
- 在事件处理函数中调用设置函数,直接更新GUI。在事件处理函数中放一个设值函数,利用它将GUI组件更新为Model的当前值,当然这其实没有必要,因为你只是拿它的值设置它自己。但是这样使用设值函数,便是允许其中的任何动作得以于日后被执行起来,这是这一个步骤的意义所在。进行这个改变时,对于组件View,不要使用取值函数,应该直接取用,因为我们稍后将修改取值函数,使其从Model对象中取值而非在GUI中,设值函数也将做类似修改。
- 编译,测试。
- 在Model类中定义数据以及相关访问函数,确保Model类的设值函数能够触发Observer模式的通报机制(update)。对于被观察的数据,在Model使用与View中相同的数据类型(通常是字符串),后续重构你可以自由改变这个类型。
- 修改View中的访问函数,使它的操作对象改为Model(而非GUI)。
- 修改Observer的update(),使其从相应的Model中将所需要数据复制给GUI。(PS:Observer模式中对于数据更新存在“推“和”拉“两种方式,这里介绍的是的“拉”数据)
- 编译,测试。
例子:
我们假设有三个文本框,一个是Start,一个是End,一个是Length,其中Length是Start和End之间的差值,你随即修改任何值,相应的另外两个都会刷新。比如你修改了Length,相应的End就会更新,你修改了Start或者End,Length就会得到更新。一开始我们的做法就是将业务逻辑都放在了View中,已知Qt中存在这样的焦点机制
void QApplication::focusChanged ( QWidget * old, QWidget * now ) [signal]
他会根据焦点的丢失,QApplication会发出相应的信号出来,这里我们需要关注的是old,因为这个指针代表了失去焦点的widget所代表的指针,我们就可以通过他来判断到底是哪个widget失去了焦点。于是我们在自己的IntervalWindow中建立与QApplication的信号槽
connect(QCoreApplication::instance, SIGNAL(focusChanged(QWidget *, QWidget *), this, SLOT(onFocusChanged(QWidget *, QWidget *))));
这样我们就可以在自己的槽函数onFocusChanged中针对上述3个widget:m_startField,m_endField,m_lengthField做对应的焦点处理
void onFocusChanged(QWidget *old, QWidget *now) { QWidget *w = old; if (w == m_startField) { startField_focusLost(); } else if (w == m_endField) { endField_focusLost(); } else if (w == m_lengthField) { lengthField_focusLost(); } }
可以看到,当任意一个指针失去焦点都会进入到相应的函数当中去,处理函数大致如下
void startField_focusLost() { bool ok; int num = m_startField->getText().toInt(&ok); if (ok) { } else { m_startField->setText("0"); } calculateLength(); } void endField_focusLost() { bool ok; int num = m_endField->getText().toInt(&ok); if (ok) { } else { m_endField->setText("0"); } calculateLength(); } void lengthField_focusLost() { bool ok; int num = m_lengthField->getText().toInt(&ok); if (ok) { } else { m_lengthField->setText("0"); } calculateEnd(); }
其中有一个需要注意的就是当用户输入的是非法字符不能成功转成数字的时候,这里将自动变成0.下面是两个具体的计算函数
void calculateLength() { int start = m_startField->getText().toInt(); int end = m_endField->getText().toInt(); int length = end - start; m_lengthField->setText(QString::number(length)); } void calculateEnd() { int start = m_startField->getText().toInt(); int length = m_lengthField->getText().toInt(); int end = start + length; m_endField->setText(QString::number(end)); }
我们的任务就是将与GUI无关的相关计算抽离出来,基本上这就意味着我们需要把calcuateLength()和calcuateEnd()放到Model中去,为了这一个目的我们需要在不能引用View类的前提下获取三个文本框的值。唯一办法就是将这些数据复制到Model类中,并且保持与GUI之间的同步,这就是Duplicate Observed Data的任务。
到目前为止我们还没有一个独立的Model类,我们建立一个
class Interval : public Observable { };
其中Observable是最简单的观察者模式接口,里面实现的就是类似notify来便利订阅自己的各个客户进行相应update。我们需要建立一个View到Model的关联
Interval *m_subject;
然后我们需要合理的初始化m_subject,并把View当作这个Model的观察者,这很简单,只需要把下面代码放到View的构造函数中就可以了
m_subject = new Interval(); m_subject->addObserver(this); update(m_subject);
我们习惯把这段代码放到构造函数的最后,其中对update的额外调用可以当我们把数据放到Model类后,GUI将根据Model类进行相应初始化。当然了,我们的View类此时应该继承Observer接口
class IntervalWindow : public Observer { };
并且覆写update函数,此时先写上一个空实现
void update(Observable *observed) { }
现在我们进行编译测试,虽然我们到目前为止还没有进行任何实质性修改,但依然需要小心。
接下来我们把注意力放到文本框上,我们从End文本框开始,第一件事情就是运用Self Encapsulate Field,文本框的更新是通过getText()和setText()来实现的,因此我们所建立的访问函数需要调用这两个函数
QString getEnd() { return m_endField->getText(); } void setEnd(const QString &arg) { m_endField->setText(arg); }
然后我们找到m_endField的所有引用点,将他们替换为相应的访问函数(这其实已经在做解耦操作,让计算逐渐脱离相关GUI的依赖)
void calculateLength() { int start = m_startField->getText().toInt(); int end = getEnd(); int length = end - start; m_lengthField->setText(QString::number(length)); } void calculateEnd() { int start = m_startField->getText().toInt(); int length = m_lengthField->getText().toInt(); int end = start + length; setEnd(QString::number(end)); } void endField_focusLost() { bool ok; int num = getEnd(); if (ok) { } else { setEnd(QString::number(0)); } calculateLength(); }
先做自我包装再做引用点更换,这是Self Encapsulate Field的标准过程,然而当我们处理GUI的时候,情况更为复杂:用户可以通过GUI修改文本框内容,不必通过setEnd(),因此我们需要在GUI事件处理函数中调用setEnd(),这个动作把End文本框设置为当前值,这没有带来什么影响,但是通过这样的方式,可以确保用户的输入确实是通过设值函数进行的,你这样就可以预防并且控制所有可能的情况。
void endField_focusLost() { setEnd(m_endField->getText()); bool ok; int num = getEnd(); if (ok) { } else { setEnd(QString::number(0)); } calculateLength(); }
细心的朋友可能会看到这里为什么没有使用getEnd()而是直接去操作文本框来获取,之所以这样做是因为我们随后的重构将使getEnd()从Model对象取值,那时如果这里使用的是getEnd(),每当用户修改文本框内容,这里就会将文本框变为原来值,所以在这里需要特别注意我们必须用直接通过文本框来获取最新值,现在我们可以编译并且测试封装后的行为了。现在我们可以给Model增加m_end字段。
private: QString m_end;
在这里我们给他的初值和GUI给他的初值是一样的,然后我们再加入取值/设值函数结果如下
class Interval : public Observable { public: Interval() : m_end("0") { } QString getEnd() { return m_end; } void setEnd(const QString &arg) { m_end = arg; setChanged(); notifyObservsers(); } private: QString m_end; };
由于使用了Observer模式,我们必须在设值函数中发出通知,在这里我们暂且把m_end的类型设值为字符串,其实作为Model本身含义来将,采用int似乎更合理,但在这个时候我们应该尽可能将修改量减到最小,以小步伐来进行重构,倘若之后成功完成复制数据,我们可以很轻松的将m_end类型改为int。
现在我们可以编译并测试一次,我们希望通过所有这些预备工作,将下面这个较为棘手的重构步骤风险降到最低。
首先我们修改View类的访问函数,令他们改用Interval对象
class IntervalWindow : public Observer { public: QString getEnd() { return m_subject->getEnd(); } void setEnd(const QString &arg) { m_subject->setEnd(arg); } };
同时我们修改update()函数,确保GUI对Interval对象发出的通告做出响应
void update(Observable *observed) { Q_UNUSED(observed) m_endField->setText(m_subject->getEnd()); }
这是另外一个需要直接访问文本框的地方,如果我们这里不直接访问,采用setEnd()本身,那么我们的GUI控件将永远更新不到,并且程序本身会进入无限递归。总结来说,在这个重构步骤中真正需要接触GUI空间本身的就两个地方:
- 在事件处理函数中,为了获得GUI控件的最新值,必须通过控件本身去获取,不然如果你通过获取Model去获取,此时的Model依然是之前的那个值。
- 在最终得到更新的时候,要去修改GUI控件的值的时候,必须调用控件的set而不是你封装的set,不然除了控件得不到更新之外你还会进入无限循环。
总结来看,一个就是用户去接触GUI的那一刻,你需要去拿最新数据的时候,还有一个就是用最终set的时候,你需要真正set到GUI控件本身。这两个地方需要特别注意,必须直接操作,而不是调用间接委托函数。
现在我们可以编译并测试,数据都被恰如其分的复制了。另外两个文本框我们也如法炮制,完成之后,我们就可以运用Move Methd将calculateEnd()和calculateLength()搬移到Interval这个Model中去,这么一来我们就拥有了一个包容Model数据和行为并且与GUI分离的专属Model了。如果我们完成了上述重构,我们还可以做更夸张的事情就是我们可以完全摆脱这个GUI,去调用更新的GUI控件,让显示效果可以更好,这个绝对是我们不进行本次重构之前很难做到的。
当然了,有些时候可能你不想使用Observer模式,你可以使用事件监听器来同样完成Duplicate Observed Data。这种情况下你需要在Model类中建立一个监听器类和事件类,你需要对Model注册监听器,就像之前Observable对象注册Observer一样,每当Model发生变化(类似上述update()被调用),就向监听器发送一个事件,IntervalWindow可以使用一个内嵌类来实现监听器接口,并在适当的时候调用适当的update()。