//========================================================================
//TITLE:
// 嵌入式UI架构设计漫谈
//AUTHOR:
// norains
//DATE:
// Friday 31-October-2008
//Environment:
// NONE
//========================================================================
和桌面清一色的采用explorer不同,嵌入式设备更多的采用是自定义的简单UI,即使是含有explorer的wince也是如此。因为对于嵌入式设备而言,功能强大并不是主打,简单易用才是根本。以目前国内的手持车载设备为例,大部分的公司卖的都是硬件,利润很大一部分取决于硬件成本的多寡。并且,每个系列的产品都会有不同的外围器件,而这也决定了无法所有的产品都用同一个UI程序。
虽然UI程序无法使用同一个,但从总体上而言,基本上是相同的;最有可能不同的地方无非是界面多了某些按钮,调用某些功能而已。另一方面,UI程序往往也需要配合产品的外观,风格尽可能和外观相符合。
于是由此,基于可重用性考虑,嵌入式设备的UI基本上必须具有如下特点:
1.界面更换方便
2.功能增删方便
下面我们就这两点具体到代码的层次去说说相应的设计。
界面更换方便,这个方便不仅是对于程序编写者而言,也是针对图片设计者。如果是方案提供商,则后者显得更为重要。如果程序能够做到每次更换图片不需要重新编译,那么对于客户而言,他们只需要重新设计图片,然后替换就能立马看到效果。这点是非常重要的,如果每更换一次图片就必须要重编译,意味着多一个客户就会多一个烦恼。
以读取BMP图片为例,最简单的方法就是将bmp图片导入到IDE环境的resource中,在使用的时候调用MAKEINTRESOURCE宏来获取相应的字符串地址即可。不过这会有一个非常严重的问题,因为图片是全部包含于可执行文件中,如果图片很多容量很大,那么单一的可执行文件的大小就会非常可观了。何况在wince中还会有个问题,如果程序体积大于8M,那么读取程序内部包含的图片将有可能会导致失败。
鉴于以上原因,图片放在外部读取是最佳的选择。
如果图片放在外部存储器,读取的速度将是一个不能忽略的问题。假使一张bmp图片的大小为800*480,然后再加上界面上的ICON,如果每次显示时都会分配缓冲然后绘制图片,那么会感觉到有延迟。一般像这种情况之下,我们都会选择一次读取,多次使用的方式。也就是说,只有第一次使用的使用才会将图片保存到缓冲区,以后都只是从这缓冲区获取图片数据而已。
为了能够最大限度节省内存,以及使用的便利性,我们将对图片的读取和获取采用类封装的形式。最为简便的方式是,我们传入一个图片的序号,然后获取一个可供绘制的HDC。
基本的形式概括如下;
namespace ImageTab
{
enum ImageIndex
{
NONE,
BKG_WND_MAIN,
...
}
struct ImageInfo
{
HDC hdc;
SIZE size;
};
}
class CImageTabBase
{
public:
bool GetImageInfo(ImageTab::ImageIndex imgIndex, ImageTab::ImageInfo &imgInfo) ;
...
}
绘制图片时可以简单如此:
ImageTab::ImageInfo imgInfo = {0};
if(m_ImgTab.GetImageInfo(m_BkInfo.Image,imgInfo) != false)
{
StretchBlt(memDC.GetDC(),0,0,iWndWidth,iWndHeight,imgInfo.hdc,0,0,imgInfo.size.cx,imgInfo.size.cy,SRCCOPY);
}
使用类的方式还有一个好处,就是如果遇到图片架构变更,只要添加新的ImageTab类即可,甚至可以通过配置文件来确定当前运行的程序应该选用何种界面:
switch(m_Option.GetImgTab())
{
case Option::IMG_A:
m_pImgTab = new CImageTabA();
break;
case Option::IMG_B:
m_pImgTab = new CImageTabB();
break;
default:
m_pImgTab = new CImageTabA();
break;
}
这对于需要频繁更改界面的需求无疑是一个比较好的方式。
和图片类似,显示的文字也是一个比较重要的议题。不像桌面PC,日常使用中只需要一种语言。当然,对于嵌入式设备,平时确实也只是一种,但这只是对于用户而言;如果对于开发者,则必须考虑到多种语言如何方便性地共存。最典型的例子便是手机,普遍性地说,手机的系统语言都会有英文,简体中文和繁体中文选项。
语言的切换其实很简单,关键在于方式的简便与否。
最为笨拙的方法无非是直接采用switch方式:
switch(language)
{
case EN:
m_Info1.SetText(TEXT(""))
break;
case CHS:
m_Info1.SetText(TEXT(""))
break;
case CHT:
m_Info1.SetText(TEXT(""))
break;
}
....
switch(language)
{
case EN:
m_Info2.SetText(TEXT(""))
break;
case CHS:
m_Info2.SetText(TEXT(""))
break;
case CHT:
m_Info2.SetText(TEXT(""))
break;
}
从代码中就可以很容易看见这种方式的弊端:每增加一个信息显示控件就必须增加一个switch语句块,每增加一个语言就必须要增加一个case语句。而对于嵌入式设备而言,增加新的控件和语言是常事,这弊端带来的结果就是不胜其烦的代码添加。更为严重的一个问题是,语言资源在代码编译阶段已经确定,如果是通过资源配置而达成文字的不同,则需要对源代码进行大量的更替。基于以上原因考虑,该方式为鸡肋。
所以还是和图片方式一样,采用类封装的方式:
namespace StrTab
{
enum Language
{
LANG_EN = 0x01,
LANG_CHS,
LANG_CHT
};
enum String
{
STR_NONE,
STR_INFO1,
STR_INFO2,
....
};
}
class CStrTabBase
{
public:
void SetLanguage(StrTab::Language lang);
virtual TSTRING GetString(StrTab::String strIndex);
};
采用类封装方式,之前通过switch语句更新资源的代码可以更改如下:
CStrTabBase StrTab;
...
//设置语言
StrTab.SetLanguage(StrTab::LANG_CHS);
...
//设置字符串
m_Info1.SetText(StrTab.GetString(StrTab::STR_INFO1));
m_Info2.SetText(StrTab.GetString(StrTab::STR_INFO2));
这样的好处显而易见,语言只需要设置一次,然后文字设置可以避免采用大量的switch语句。还有另外一个不为人注意的好处是,字符串的设置只和CStrTabBase的GetString函数有联系,而不管其内部是如何获得的。也就是说,语言的资源即可以在编译时确定,也可以在运行时获取,但究竟采用何种方式,对于界面的字符串设置代码来说都是一致的,并不需要做任何更改。
因为动态获取语言资源方式繁多,在此不再累赘,只是简单说说如何编译时期确定语言资源如何才能做到最简便。如果还是像之前采用switch块,则显得有点换汤不换药的味道。鉴于此,我们采用STL库的map。
我们使用三个map变量,用来存储相应的语言资源:
std::map<StrTab::String,TSTRING> mpChinesSimplified;
mpChinesSimplified.insert(std::make_pair(StrTab::STR_TV,TEXT("移动电视")));
m_mpString.insert(std::make_pair(StrTab::LANG_CHS,mpChinesSimplified));
std::map<StrTab::String,TSTRING> mpChinesTraditional;
mpChinesTraditional.insert(std::make_pair(StrTab::STR_TV,TEXT("數位電視")));
m_mpString.insert(std::make_pair(StrTab::LANG_CHT,mpChinesTraditional));
std::map<StrTab::String,TSTRING> mpEnglish;
mpEnglish.insert(std::make_pair(StrTab::STR_TV,TEXT("TV")));
m_mpString.insert(std::make_pair(StrTab::LANG_EN,mpEnglish));
获取函数则可以非常简单:
TSTRING CStrTabBase::GetString(StrTab::String strIndex)
{
return (m_mpString[m_Lang])[strIndex];
}
以后当我们需要添加新的字符串资源时,只需要在初始化添加相应的字符串即可。这样不仅避免了大量的case语句,还能令代码条理清晰,方便简洁。
细心的朋友可能发现,CImgTabBase和CStrTabBase有所不同。对于图片资源来说,不同的方案,表现的图片会不一样,比如说,同样代表“设置”的图标,可能给A公司和给B公司的完全不同(否则就撞车了);但文字资源,无论是A公司或是B公司,功能都叫“设置”。因此,图片类实际获取资源的是取决于子类,而文字资源则是基类。文字资源的子类,只是更改部分某些不同的数值而已。
与此相对,一些常用的数值也可以先用类封装,方便之后的更改:
namespace ValTab
{
enum CtrlColor
{
COLOR_TXT_ITEM,
COLOR_TXT_WND_TITLE,
...
};
enum CtrlSize
{
TXT_ITEM_WEIGHT,
TXT_ITEM_POINT_SIZE,
...
};
}
class CValTabBase
{
public:
virtual COLORREF GetColor(ValTab::CtrlColor ctrlColor);
virtual DWORD GetSize(ValTab::CtrlSize ctrlSize);
};
类似于此,很多随着环境会改变的数值,按钮的位置等等,都可以采用此形式封装。
如果全部采用封装形式,每次添加新值只需要在初始化中添加相应的数值即可:
BOOL CMainCtrl::Initialize(HINSTANCE hInstance)
{
switch(m_Option.GetImgTab())
{
case Option::IMG_A:
m_pImgTab = new CImageTabA();
break;
case Option::IMG_B:
m_pImgTab = new CImageTabB();
break;
...
//如果有新值,在这里添加
...
default:
m_pImgTab = new CImageTabA();
break;
}
switch(m_Option.GetPosTab())
{
case Option::POS_A:
m_pPosTab = new CPosTabA();
break;
case Option::POS_B:
m_pPosTab = new CPosTabB();
break;
...
//如果有新值,在这里添加
...
default:
m_pPosTab = new CPosTabA();
break;
}
switch(m_Option.GetValTab())
{
case Option::VAL_A:
m_pValTab = new CValTabA();
break;
case Option::VAL_B:
m_pValTab = new CValTabB();
break;
...
//如果有新值,在这里添加
...
default:
m_pValTab = new CValTabA();
break;
}
switch(m_Option.GetStrTab())
{
case Option::STR_A:
m_pStrTab = new CStrTabA();
break;
case Option::STR_B:
m_pStrTab = new CStrTabB();
break;
...
//如果有新值,在这里添加
...
default:
m_pStrTab = new CStrTabA();
break;
}
m_pStrTab->SetLanguage(m_Option.GetLanguage());
return TRUE;
}
万变不离其宗,对于windows程序而言,最主要的还是窗口。很多时候,大家常用的做法是一个界面,就写一个源代码文件。这样当然简单,但带来的问题就是代码重复度高,没增加一个窗口就要增加一个文件,显得很累赘。所以,关于窗口,我们是采用只用一个窗口类,通过设置不同的数值,来生成形式各异的界面。
我们先来分析一下像这种简单UI程序不同界面的区别。一般来说,像界面无非是有如下控件:
1.按钮:用来实现特定功能
2.文本:用来显示不同的文字
3.进度条:用来显示某些特殊功能的状态
4.背景:窗口的背景图片。
这四点,便是界面的共通点。所以我们在设计窗口类时,给这四点留出接口即可:
class CUserWnd
{
public:
BOOL SetButtonInfo(const std::vector<UserData::ButtonInfo> &vtBtnInfo);
BOOL SetTextInfo(const std::vector<UserData::TextInfo> &vtTxtInfo);
BOOL SetProgressInfo(const std::vector<CProgress> &vtPrgInfo);
BOOL SetBackground(UserData::BackgroundInfo bkInfo);
...
}
然后对于不同功能的界面,我们只需要设置不同的数值即可:
//背光窗口
pWndBacklight->SetButtonInfo(vtBtnInfo1);
pWndBacklight->SetTextInfo(vtTxtInfo1);
pWndBacklight->SetBackground(bkInfo1);
//电池窗口
pWndBattery->SetButtonInfo(vtBtnInfo2);
pWndBattery->SetTextInfo(vtTxtInfo2);
pWndBattery->SetBackground(bkInfo2);
这样我们只需要一个窗口类,就能实现变化各异的界面。
在这里还有一点需要提一下,一般来说,我们显示界面和功能的实现应该分开,这样代码看起来就不会变得杂乱无章。我们可以采用这么一种做法,定义一个CCommand类,主要是执行按钮的相关功能操作,然后窗口类继承于该类即可:
class CCommand
{
protected:
BOOL ExecuteCmd(UserData::CtrlIndex ctrlIndex,DWORD dwParam);
....
}
BOOL CCommand::ExecuteCmd(UserData::CtrlIndex ctrlIndex,DWORD dwParam)
{
switch(ctrlIndex)
{
case UserData::BTN_EXIT:
{
return OnCmdExit();
}
case UserData::BTN_EXPLORER:
{
//Execute the explorer and then exit the application
CCommon::Execute(m_Option.GetPathTab(Option::PATH_EXPLORER).c_str());
}
...
}
}
//窗口的继承
class CUserWnd:
public CCommand
{
...
}
基本上,嵌入式UI架构的设计如此了。其实,只要能做到界面更换简单,功能添加简便,基本上往后的工作就会非常容易,所以初始的架构设计就显得非常重要了。
后记:好久没写这种纯理论到连自己看了也觉得不知所云的东西了...文中的代码是从我所写的工程中搬出来的,直接用到别的地方肯定是行不通,所以大家仅仅是看看,做做参考就好。:-)