说起GacUI(http://www.gaclib.net/,gac.codeplex.com),其实这个想法在我还在上大三的时候就已经有了。但是由于经验不足,在当时并没能够把这个东西给做出来,直到去年(2011)的国庆节为止。想想到现在也做了快一年了,GacUI也可以用来写一些不是特别残暴的C++GUI程序了。前几天有人问道,为什么在PC都快完蛋了并且大部分GUI都已经用C#来做的时候,我还要做这个东西呢?其实,这有两个原因:第一个我喜欢折腾C++;第二个C++好像也没什么特别好的GUI,因此也想尝试一下,如果做成了就维护下去,做不成了好歹还可以提高自己的水平,总之是不会浪费时间的。所以我就在想,GacUI写到现在也快一年了,并且我最近也看到cppblog上面有几个人也想搞搞GUI,因此我想把GacUI的一些设计思想,和我得到这些思想的过程写出来,顺便也介绍一下GacUI的架构,让一些有兴趣的人(特别是装配脑袋)也可以来折腾折腾。
GacUI的架构的最重要一点就是要跨平台。当然这不一定意味着我将来一定会把GacUI移植到别的什么操作系统去,但至少Windows的Classic Desktop和Metro的两套API就毫无相似之处,同时搞定他们,也算是跨平台了。而且就算是基于同一种API,上面还有不同的渲染器的API,譬如说GDI,譬如说Direct2D,他们也是截然不同。GacUI的设计至少要可以屏蔽掉他们的区别。当然,这在技术上有一个很好的方法来保证,就是GacUIIncludes.h里面不包含Windows.h的任何内容——因此至少在头文件里面,所有的东西都是跟Windows无关的。当然在非GUI的部分,我们还是需要Windows.h的,并且有些人喜欢对GacUI做点hack的操作,因此我还是在GacUI.h里面提供了几个额外的依赖于Windows.h的函数来暴露一些内部细节。那这样如何跨Classic Desktop和Metro呢?有一个简单的方法,就是可以在编译的时候给些宏开关,譬如说GACUI_WINDOWS_CLASSIC_DESKTOP(缺省)或者GACUI_WINDOWS_METRO之类的东西,来屏蔽掉不需要的部分。当然这部分在移植到Metro之前我不会加进去。
基于这个想法,如果大家阅读了GacUI的代码的话,会发现在文件\Libraries\GacUI\Source\NativeWindow\GuiNativeWindow.h里面定义了一个INativeController接口,而且目前只有Windows Classic Desktop一个实现。INativeController的内容很多,提供了跟具体的平台有关的操作,譬如说读写图片文件啦、创建消灭窗口啦、显示器操作啦、还有各种其他的输入输出等等。实现一个从头INativeController还是比较繁琐的,因为GUI这种对操作系统重度依赖的东西,想剥离开来,就会发现他依赖了一大坨API。这也解释了为什么INativeController的各个XXXService函数返回的对象的方法的总和有上百个。不过从Classic Desktop移植到Metro还是相对比较简单的,因为大部分内容还是可以共享的。
其次就是渲染器了。渲染器跟平台是交叉依赖的。譬如说OpenGL在linux上和Classic Desktop上都可以用,Direct2D在Classic Desktop上和Metro上都可以用,GDI只能在Classic Desktop上面用。因此这就是为什么我最终没有把渲染器也写在INativeController里面,而是把渲染器整个给屏蔽掉了,根本没有在GacUIIncludes.h里面给出他的接口。但是考虑到GacUI是一个支持换肤的GUI库,因此肯定需要让皮肤来自己决定如何绘图。后来我就想了一个办法,把渲染器的结构整个拿掉,替换成各种各样的图元(IGuiGraphicsElement)。所谓的图元就是类似于方形啊,圆形啊,填充啊,渐变啊,文字之类的东西。皮肤自己把图元按照一定的排版关系(在下文中有描述)拼装好,然后GacUI内部的一个小系统会利用Bridge和Abstract Factory两个模式的结合体(参考\Libraries\GacUI\Source\GraphicsElement\GuiGraphicsElement.h)来为这些图元分配好渲染器对象(IGuiGraphicsRenderer)。然后图元和渲染器之间用了Listener模式在交换信息。这样的好处是,当图元受到改动的时候,这个图元对象的专用渲染器对象可以选择cache一些信息,然后在窗口渲染的时候,只需要访问所有的渲染器对象(在排版对象GuiGraphicsComposition的组合项形成了一棵树),让他们渲染自己就可以了。
图元包含了所有需要渲染的数据,但是唯独没有把尺寸写进去,因为尺寸这种东西不应该让渲染器来负责,而应该让排版对象来负责。排版对象自己是一棵树,然后节点根节点之间有一些关系,这样就可以实现堆栈排版、表格排版、对齐(到某一些边上的)排版等等具体的排版算法。一个排版对象可以放置一个图元对象并让这个图元充满他,所以显而易见,有一些排版对象仅仅是用来计算尺寸的中间结果,上面不一定有图元对象的。当渲染开始的时候,排版对象首先跟图元对象获取数据,然后递归计算好整棵排版树的尺寸,最后把尺寸交给附着在上面的图元对象的专用渲染器对象来渲染。
大家可能会想,如果渲染一次都需要调用成千上万个虚函数的话,会不会性能低下啊?当然编译成Release运行会发现GacUI的性能还是相当高的。原因有两个。第一个是我对排版对象做了一些优化。举个例子,一个对象的尺寸至少要大于所有子对象的尺寸,这个事情计算起来是相当快的,不需要做cache。但是一个表格排版里面的所有小格子会互相挤来挤去,这个东西计算起来相当复杂(复杂度大越是平方,而且系数也不笑),所以结果要做cache。但是什么时候需要重新计算呢?度量方法很简单,就是每一个格子的最小尺寸发生了变化的时候。而且事实上大部分皮肤都是用表格来排版的,所以等于说大部分结果都有cache。所以排版部分的尺寸在每一次渲染的时候只需要做一些小计算就可以了。复杂的排版每一个排版对象相互之间都是有关系的,一个排版对象发生了变化,有可能导致另一个排版对象的尺寸需要修改,所以最简单的方法就是,不保存尺寸,每一次都直接重新算一次就可以了。在这个基础上,表格排版做一下cache,整个计算过程就会变得飞快。所以尽管每一次拖动窗口,或者鼠标滑过一次窗口,都要进行相当多的计算,但是因为有一个智能的cache,使得不仅运算速度变快,而且在添加新的排版对象类型的时候也根本不需要考虑自己会不会被cache的问题,开发起来也相当愉悦。
所以上面的三大模块(操作系统API隔离、渲染器、排版对象)已经足以让我在系统里面开一个窗口然后在上面放各种各样的东西了,譬如说组合成一个非常接近Windows7的按钮外观的一个矢量图。那控件要怎么办呢?其实一个控件,就是通过接收用户的输入,对一个排版对象上承载的一大堆图元进行更改。用户的输入和控件(GuiControl)本身的状态进行互动,然后控件把状态的变更提交给控件的皮肤(GuiControl::IStyleController),最后皮肤通过修改图元来把状态变更最终展现给用户。一个典型的例子就是,在使用Windows7皮肤的时候,鼠标移动到按钮上面去,他会触发一个动画慢慢变成蓝色。
GacUI的大体架构就是这个样子了。在接下来的几篇文章里面,我会详细介绍每一个子系统的内部结构,顺带做以下代码导读,大家敬请期待。