在Windows平台上开发客户端产品是一个非常痛苦的过程,特别是还要用C++的时候。
尽管很多语言很多方法都可以开发Windows桌面程序,目前国内流行的客户端产品都是C++开发的,比如QQ,YY语音,迅雷等。
快速,稳定是我认为的应用软件开发框架最基本的要求,对于UI还有两个要求就是界面美观,配置灵活。
C++语言满足了快速的要求,传统的客户端软件开发框架如MFC,WTL等满足了稳定的要求。然而界面美观,配置灵活是MFC,WTL这样的开发框架所不能满足的。
腾讯是做客户端发家的,他们的UI经验积累非常好,有自己专门的UI框架;迅雷有一个专业的团队开发自己的UI框架;然而大多数公司只希望有一个能够快速完成项目开发的UI库来使用,它们没有专业的团队来维护UI库。国企有钱任性,所以成就了UIPower:一个商业化的DirectUI库(具体怎么样不好说,优点在于有人给你服务),一般的小公司没有谁愿意当这个冤大头。这就是Duilib这样一个简单到简陋的UI库(请原谅我这样说)为什么这样流行的原因(百度一下Duilib就知道它有多少人在用)。
Duilib基本满足了界面美观 ,配置灵活的需求,然而由于框架本身的限制,要实现复杂的效果将不可避免的遇到各种坑。好在Duilib代码量很少,随便一个有经验的UI开发工程师都能够相对容易的使用并修改它,所以在一般的应用中使用并不会有太大的问题,这也应该是为什么会有那么多的Duilib变种的原因:每一个使用它的公司或者个人都会有一份独一无二的副本。
其实上面我还漏了说QT, QT在国外有专业的团队维护,文档也很好,但至少有两个缺点:1、它是跨平台的,跨平台即是优点,也是缺点,为了实现跨平台,很多时候需要做出取舍,就算抽象的100%的完美,它也不可避免的带来体积庞大;2、代码量太大,普通人很难驾驭:就算是看懂都不容易,更别说修改了,这样的结果就是一旦在使用中遇到问题你唯一的选择就是提交BUG给QT开发小组等待补丁(要知道不存在没有BUG的产品)。
SOUI是一套和Duilib类似的开源C++ UI开发框架。它的祖宗是金山卫士开源版本中使用的UI库Bkwin,之后由启程软件(也就是我了)开发维护升级为Duiengine,最后历经多次重构改名为SOUI,寓意“瘦UI”,“UI, just so so!”
SVN: http://code.taobao.org/svn/soui2/trunk 不要在浏览器中打开该网址,只能使用SVN客户端签出。
GIT: https://github.com/setoutsoft/soui
使用层:高速,稳定,美观,可配置
代码层:精心设计,模块低耦合,插件化设计,对象可靠的命令周期管理,类似WTL的编码方式,现代化的事件处理模型及优异的扩展能力。
代码量:核心模块代码量4W+,编译后DLL Release版本在900K左右。得益于精心组织的代码框架,虽然代码量较Duilib这样的UI库有比较大的提高(核心框架更完善,控件更多,注释量更大),但是阅读代码还是很轻松的(大量实际用户的亲身体验)。
高速主要体现在3个方面:
1、框架设计扁平化,层次简单(和QT相比):从宿主窗口收到消息到控件响应消息只有一个中间层。
2、简单有效的刷新策略:通过对剪裁区及刷新时机的有较控制, 能有效的提高刷新效率。
3、高效的渲染引擎:通过 将渲染引擎接口化,成功的将skia渲染引擎引入到SOUI中,Skia是Google的Chrome的渲染引擎,Chrome比IE渲染速度快,Skia功不可没。
稳定性方面,SOUI脱胎于Bkwin,再经过本人的不断精心重构,已经在多个大量用户的产品中应用,包括最近开发的瑞雪医生客户端,多玩魔盒2.0, Dota2游戏盒子及多玩多个游戏盒子中使用, 及百度云管家的大部分界面。
百度云管家据说最初使用的是腾讯QQ界面库的早期版本(无从考证),然而QQ界面库大量使用COM技术,扩展非常麻烦,使用很是不便,在后续的UI需求中开始大量使用SOUI的前身DuiEngine。
美观方面,SOUI原生支持Alpha通道,能够实现各种半透明效果,包括主窗体半透明,DUI窗口半透明,DUI窗口模仿LayeredWindow(分层窗口)效果等,轻松实现各种异形效果。
可配置方面,SOUI中所有UI资源都采用XML描述,调整UI效果一般只需要修改XML资源即可完成。
说到代码层的设计很难用语言描述,只有亲自阅读代码方能理解。为大部分需要在外部(APP层)经常引用的UI相关对象提供引用计数设计能够有效减少C++开发中常见的野指针问题,这一点还是很好体会,同时系统中也重点解决了如消息分发的分层设计,窗口对象的消息重入等影响UI使用体验的关键性问题。
宽泛的说SOUI多好大家并没直观的感觉,下面从一些具体的点来介绍SOUI。
界面布局
也许初学者对于SOUI的布局还不太适应,特别是对于那些习惯了Duilib的布局方式的朋友。事实上SOUI的布局应该是最接近程序思维的布局方式。前段时间开发Android,仅仅是它的5大Layout就能让人崩溃,而且不同的layout对应的布局属性还不一样。
SOUI的布局非常简单,只有两个布局属性:pos + offset,具体参考博客: http://www.cnblogs.com/setoutsoft/p/3925952.html
通常使用一个pos属性就解决布局问题了,pos在XML中使用"x1,y1,x2,y2"这样的4个坐标定义一个控件在父窗口中的相对位置,而offset则定义通过pos计算出来的位置后在X,Y两个方向需要叠加的偏移,偏移值需要乘上窗口大小。
例如下面这个需求:
只知道窗口需要靠右下角,不知道窗口大小的情况,在SOUI中只需要使用属性pos=“-20,-30” offset="-1,-1"即可。
渲染流程
一个UI中的界面元素最后会通过各级子窗口形成一个树状结构。一般的渲染流程自然是从根节点一层一层的直到渲染完成所有叶结点。这个过程很简单,可能很多UI库也就做到这个层次(例如DuiLib)。但是对于一个高性能的UI库仅做到这个层次是不够的,举例来说:一个画笔程序需要在OnMouseMove里面绘制新拾取的线条,本能的做法是获取窗口画布,绘制完成后再提交画布(类似Windows API: GetDC and ReleaseDC),而不是每一次绘制只能请求宿主刷新(请求宿主立即刷新依赖于系统对UpdateWindow这个API的响应速度)。
因此一个成熟的UI引擎有必要实现GetDC及ReleaseDC这样的接口。和基于HWND的窗口获取HDC不同,在一套DirectUI系统中实现GetDC及ReleaseDC要更加复杂:最关键的问题在于获取前绘制窗口的背景,以及提交后绘制窗口的前景,要实现窗口背景前景的分开绘制又需要系统提供绘制在指定Z-Order范围内的窗口的能力,当然前提是系统中有Z-Order这样的概念。
就算实现了窗口的背景与前景的分别绘制,对于一个高性能的UI引擎可能还是不够的。因为有些时候一个窗口中的内容是不需要和背景混合的,窗口刷新的时候绘制背景是没有意义的(如视频播放窗口),就是需要另一种技术:窗口的跨层渲染(不知道这样命名是不是合适)。当一个视频窗口需要刷新的时候,它的刷新流程和基本的刷新流程是不一样的,渲染时它会跳过它的所有父窗口直接到这个窗口层来,从而大大加速渲染过程。
分层窗口
Windows的分层窗口是Windows 2000提供的一项重要更新。苹果系统的UI很漂亮,有了分层窗口,Windows系统上开发的应用也可以同样漂亮。
这里说的分层窗口有两个层次:一个是DirectUI的宿主窗口中使用分层窗口技术;另一层是在DirectUI的DUI窗口系统内部实现分层窗口技术。
使用分层窗口技术听起来比较简单,不就是设计一个WS_EX_LAYEREDWINDOW属性再使用UpdateLayeredWindow(EX)更新窗口吗?!如果SOUI只达到这个层次,那和codeproject上随便找一个demo也没有什么区别。
首先要搞清楚,SOUI是一套DirectUI系统,而不是Demo,因此它不能停留在加载一个32位PNG图片并显示出来这样的层次上。它必须要能够让用户能够调用各种绘制图形,图像,文字的API来组合出一个最终需要呈现的32位位图。这一点要求看起来简单,在Windows系统上实现起来并不简单,因为Windows上最基本的绘制API(GDI)都是不支持alpha通道的。有一个简单的选择:GDIPlus。然而GDIPlus有一个毛病就是速度太慢,这对于一个通用的UI引擎来说,全部依赖GDIPlus基本上就宣判了这个引擎的死刑。在SOUI中采用渲染引擎抽象的方法实现了两种渲染引擎:Skia + GDI。前面不是说GDI不支持Alpha通过不能用吗?没错,直接用GDI函数是不行的,我们需要适当的改造(具体方法参见代码)。
解决了绘制方法,要更新到窗口中显示也还是有技巧的。有人可能知道,使用UpdateLayeredWindow这个API更新的窗口将收不到WM_PAINT消息。由于在半透明窗口中不能直接支持有窗口句柄的子窗口的显示(如IE控件),SOUI还必须为那些需要容纳窗口句柄子窗口的情况提供支持,即通过配置同时支持半透明窗口与不透明窗口。但是我不愿意为两种不同的最终位图呈现模型提供两套不同的机制。解决的办法很简单,通过为半透明类型的窗口设计一个辅助窗口,使用它来接收WM_PAINT消息,收到该消息时调用UpdateLayeredWindow更新窗口。注: 这个技术是学习另一套UI库MetalBone实现的。
讲完了使用宿主窗口分层窗口,下面讲讲DUI窗口的分层窗口技术的实现。
使用分层窗口技术能够使UI效果更漂亮,关键技术就在这个层。层是什么?层是一组窗口的绘制容器,它将该层下所有子窗口的绘制内容绘制到一个独立的缓冲区上, 最后再一起绘制到分层窗口的上一层绘制缓冲区中。如下图:
A、B、B1、B2、C为DUI系统中5个DUI窗口。其中,B、B1、B2是同一个渲染层。也就是说设计需要它们先绘制好后再和A,C做融合。 类似的需求对于一个漂亮的UI来说可能会很常见。如果在UI引擎中没有层的概念是不可能实现的。如果不需要实现前面提到的背景和前景分别渲染的情况,实现会层窗口其实也不难,只需要在渲染到B窗口时创建一个缓冲区,把从B开始的内容渲染到这个缓冲区,完成后再回到正常渲染流程,就像没有B1、B2一样。但是SOUI是支持背景前景分别渲染的,实现这个过程的代码逻辑就可能很复杂了(可以自己想象一下)。
非客户区
HWND的非客户区用来绘制滚动条及边框及标题栏,菜单栏。客户区是用户绘制的常规区域,在设计上将窗口的显示区域划分为客户区和非客户区,有利于用户在重写客户区的绘制代码时不被非客户区干扰,也有利于代码的复用。
在DuiLib中,一个控件如Richedit需要显示滚动条,它需要给这个控件组合两个滚动条控件。这种方式虽然看上去没有什么大的问题,如果由于窗口中内容的变化需要动态显示隐藏滚动条时可能会很麻烦,至少它会引起窗口布局系统的重排,因为滚动条显示和隐藏时控件的客户区大小是变化的。
而在SOUI系统中,滚动条和HWND一样,用户根本不需要关心,因为内部已经自动处理好了滚动条,也不会引起布局系统的重排。
资源加载
一般来说SOUI中引用的所有资源都在XML中描述。刚入门的朋友通常反映SOUI中使用资源的方式不如DuiLib直接,很难入门。但是一旦真正理解了SOUI的这种资源组织方式一定会更喜欢SOUI。
SOUI提供3种资源加载方式:文件,PE资源,ZIP包。
首先SOUI的资源包必须提供一个文件索引表,对于使用PE资源的资源包,索引表就是资源的类型及ID,而对于直接使用文件或者ZIP包的资源,索引表则是一个XML文件。在索引表中,定义每一个资源的type及name两个KEY,SOUI界面布局中只能使用type和name两个key来引用资源。
用户只需要准备一套文件资源,如果需要将资源编译到PE文件中,系统提供一个工具直接从文件资源的索引XML转换成rc编译器可以识别的rc文件;而如果用户需要使用ZIP资源包,则只需要使用一个ZIP工具如rar, 7z将资源文件夹打包即可(推荐使用7z打包资源,SOUI内自带的zlib 1.2.5能够识别7z打包的带密码的zip包,但不能识别rar打包的带密码的zip包。
窗口动画改进
一般情况下我们推荐使用窗口定时器来创建动画。使用窗口定时器创建动画的好处是定时器和UI是同一个线程,而SOUI不支持多线程同步更新UI(事实上一般的DirectUI库都不推荐在工作线程中操作UI,如Android)。那么问题来了,如果为每一个DUI窗口创建很多定时器,那么系统的消息队列中将充满定时器消息,严重时可能大大降低UI性能。
解决方案:在主窗口中创建一个10ms间隔的定时器,需要处理动画的窗口向系统注册使用该定时器,动画记录下一次动画需要等待的时间,使用该统一的定时器计数。
我们看一下面DEMO中显示大量动画表情时SOUI的效率:
这一CPU占用率甚至比QQ中同样情况下还低。
容器分层
什么叫容器分层?在DirectUI中所有的DUI窗口都必须生存在一个容器中。DUI窗口的绘制请求等最终需要由这个容器来实现。在容器不分层的情况下,所有DUI窗口在容器中的物理坐标都是从(0,0)开始。这样有什么问题呢?如果要在列表控件的列表项中使用DUI控件就会变得非常麻烦,因为在窗口滚动时你可能不得不同时更新所有这些控件的坐标。
有了窗口层的概念就不一样了,每珍上列表项是一个新的容器,无论列表项显示在哪,列表项中的控件(容器中的控件)的坐标都不需要调整。因为有了容器分层,在SOUI中实现包含子控件的列表变得非常简单(参考下节:高性能列表控件)。
高性能列表控件
Windows系统中提供的列表控件非常简单,只能满足简单的数据显示需求。注意,是显示。然而现在的UI需求中经常出现那种即时修改列表控件内容的情况,你将不得不花大量的时间对列表控件进行自绘,而效果只能说勉强。
通过研究Android系统中提供的列表控件的代码,借鉴Android中ListView的思想,SOUI实现了一套高性能的列表控件SListView及SMcListView。
SListView及SMcListView都是基于虚表技术,同时只创建当前正在显示的及部分备用的列表项容器,将资源占用缩小到最少。同时ListView在滚动时能够高效刷新,实现了海量数据的高性能显示及更新。
实现这个高性能列表控件的关键有两点:
首先是SOUI中实现的容器层的概念,使得列表位位置变化时,容器内部的控件不需要调整坐标。
其次就是容器数据的充分重用。
注:上面列表中只测试了7W行数据,实际上listview中显示的数据量多少完全不影响UI性能,亲测700W行数据和7W行效果一样。
无窗口Richedit
Edit控件是UI中最常用的控件之一。在允许存在子窗口句柄的情况下,系统Edit控件已经能够很好的满足我们的需求。然而在不允许子窗口句柄的情况下,实现一个Edit控件会非常麻烦。
当然,程序可以选择自己去重新实现一套edit,Edit也许还可行,一般情况下要实现一个Richedit基本不可行。 好在实现Richedit的模块riched20.dll中把UI和逻辑分离开来,即可以用它直接创建有窗口的Richedit,也可以用它来创建提供无窗口Richedit的ITextServices接口。然而即使是这样,程序员需要为ITextServices实现一个ITextHost接口。尽管MSDN上有相关的文档及示例,但是根据它们提供的这些资料实现的效果很不理想。必竟只是Demo,不是完整的代码,它不能演示开发中可能遇到的每一个细节。然而恰好是这些细节是影响UI用户体验的关键。
所以我们需要另辟蹊径来解决这个问题。我解决这些细节,关键在于理解它们的逻辑。SOUI的办法是找到riched20.dll的源代码。好在网络上流传着一份从WinCE源代码中分离出来的Riched20.dll的源代码,虽然用它编译出来的Richedit有很多BUG,但利用它可以让我们更好的理解各种细节。大家可以测试SOUI中的Edit,效果应该是各种类似库中最好的一个之一。
XML+LUA
部分模块在SOUI中采用了接口化设计,如前面提到的渲染引擎,以及后面要说的多语言翻译,以及这里要说的脚本模块。
脚本语言方便灵活,更新简单,LUA脚本还有高效的特点。和WEB的HTML+JS类似,SOUI实现了XML+LUA的UI开发解决方案。XML实现UI布局,LUA实现逻辑控制。
实现方法:
在XML中使用<script>标签声明UI中需要脚本支持。
通过XML创建UI时自动从脚本模块为该UI实例化脚本对象。
采用lua_tinker自动导出C++类到LUA脚本空间,包含控件对象,及控件事件对象。
在LUA脚本中处理事件响应。
多语言翻译
多语言翻译对于需要国际化的应用来说可能非常重要。SOUI通过一个语言翻译接口来执行特定上下文的多语言翻译并且实现了一个类似QT语言翻译功能的基本XML的语言翻译模块。用户只需要按照Demo中的语言翻译文件的组织方式组织翻译XML就可以了。
String及其它基于模板的集合对象的参数传递
由于String通常要同时支持char及wchar_t这两种字符类型,通常String在一个类库都都是以模板形式存在,比如WTL,ATL,(MFC太久不用,记不清了)。使用模板实现的对象有一个特点,那就是代码会编译到使用它的模块中。如此一来,如果在这些模板类中直接调用malloc, new等内存分配函数时会在调用模块的堆上分配内存,相应地,内存的翻译也需要在调用者模块中执行。
这有什么问题呢?最大的问题莫过于这样的对象不宜在不同的模块之间比参数进行传递(当然,const参数是没问题的)。如果一个这样的对象在A模块中分配的内存到B模块中被翻译,结果只有崩溃。(如果所有模块采用MD方式动态链接VS的运行库是没有问题的)
很多小软件是不希望采用MD编译的,因为这样的话为了确保程序的正常运行,还需要带上VS中相对应的运行库,尽管体积不大,但也麻烦。
SOUI中采用了一点技巧,所有上述模板类都在一个独立的模块中实现,同时改写了这些类中的内存分配及释放代码。将它们重位向到该模块中的两个内存分配释放方法。经过这样处理后,不管这些模板类在哪一个模块中实例化,它们要在堆上申请及翻译内存时都是在这个独立模块中。通过这个简单的技术有效的解决了这些模板对象不能在不同模块之间传递参数的问题。
先进的事件处理模型
SOUI同时支持类似WTL消息映射表方式的事件映射表来响应事件,也支持新式事件订阅的方式响应事件。事件映射表处理事件的优点在于能够规范化的把所有事件处理方法在代码水平集中到一起,方便代码的阅读;而事件订阅则提供了事件的动态处理能力,能够在任意时刻灵活的响应不同控件发出的事件。
除上述亮点,我相信还有很多细节的处理都体现了SOUI的工匠精神,相信用心的朋友一定可以在阅读及使用代码的过程中更深的体会到。SOUI是启程软件历时5年心血的结晶,重复一下我以前做《启程输入之星》时说的那句话:因为努力,所以美丽!
希望能够为您能够喜欢。