作者:刘树伟
前段时间使用LibUIDK界面库为客户定制一个IM软件界面,需要把聊天记录以气泡形式展示出来。目前,国内IM软件显示聊天气泡主流的分为RichEdit和网页两派。不论从稳定性、兼容性、性能、体积、用户体验方面,RichEdit都完胜网页。不过,RichEdit开发气泡效果,难度非常大,非一般公司和个人可以解决,现在国内编程都走快餐经济,很少有人能静下心来,花几个月时间研究这个。RichEdit显示气泡,原理极其简单,但具体开发的时候,坑实在是太多。所以,我写下本文,让大家有个参考,少走弯路。
RichEdit分有句柄和无句柄两种模式,它们原理一样,本文以带句柄的RichEdit讲解。并且为了兼容性,我们也不假定RichEdit的版本,也就是说,本文的方法,适配从xp到win10各版本的RichEdit,这样,用户在使用本文方法制作出的聊天控件发布的时候,不需要附带RichEdit的DLL。
零、题外话:
制作IM软件的聊天记录控件,目前有两个阵营:一种是使用传统RichEdit插入OLE控件实现;另一种是使用CEF实现。这里,我们只讨论RichEdit插入OLE的方案。
OLE、COM、ActiveX的关系:
OLE技术以COM规范为基础,OLE技术只是COM规范的一个应用而已。COM是组件程序与客户程序之间进行通信的一种规范。这里有件有趣的事情,虽然OLE是基于COM的,但OLE却早于COM,是不是很费解?其实是这样的:在早期,没有COM,只有OLE,在OLE发展的过程中,产生了COM,或者说,把OLE的规范,抽象出来产生了OLE。这就类似于我们准备写个按钮控件类CMyButton,随着CMyButton的发展,我们发现,可以把CMyButton某些东西提取出来,生成一个控件基类CMyControl,因为以后我们可能要写个从CMyControl派生的CMyComboBox。所以,虽然CMyButton是从CMyControl派生的,但CMyButton比CMyControl出现的早,也是可能的(真实的情况是:OLE 1.0时,组件程序与客户程序之间进行通信是使用的DDE,即动态数据交换机制,到了OLE 2.0时,放弃了DDE,采用了COM)。实际上,COM从OLE抽象出来后,确实出现了一系列以COM为基础的新技术,它们统称为ActiveX技术。
组件、接口、方法
在COM世界中,C++的类,在COM中叫接口,而C++的成员函数,叫接口方法。C++类所在的DLL,叫组件。所以,一个组件可以包含多个接口。
下面所讲的方法,无特别说明,都指带句柄的CRichEditCtrl。
术语:
cp: char position:字符位置索引
格式:无特别说明,这里的格式仅指段落的左缩进、对齐方式、行间距。
一、RichEdit用到的COM接口
-
IRichEditOle
IRichEditOle是RichEdit中一个非常重要的COM接口,大部分其它接口都与它有直接或间接的关系,很多接口由IRichEditOle查询得到,往RichEdit中插入OLE对象,也是通过这个接口。
IRichEditOle *pRichEditOle = GetIRichEditOle();
-
ITextServices
RichEdit有两种封装,一种是基于HWND的传统方式,另一种是无句柄的windowless方式,但内部实现,只有一种。那就是基于ITextServices的实现。
创建windowless的Richedit,其实就是创建Text Services。可由API CreateTextServices完成:
ITextServices *m_pserv; // 类成员
CComPtr<IUnknown>pUnk = NULL;
if(CreateTextServices != NULL)
{
HRESULThr = CreateTextServices(NULL, this, &pUnk);
}
HRESULT hr =pUnk->QueryInterface(IID_ITextServices, (void **)&m_pserv);
对于基于HWND的RichEdit,在创建好之后,可以通过IRichEditOle接口查询得到:
HRESULT hr =pRichEditOle->QueryInterface(IID_ITextServices, (void**)&m_pTextServices);
由于ITextServices接口很常用,可以保存成类成员,不必每次获取。
-
ITextDocument
ITextDocument接口一般不单独使用。常使用它的Range方法,得到一段范围的ITextRange接口。
通过IRichEditOle接口,可以查询到ITextDocument:
HRESULT hr =pRichEditOle->QueryInterface(guid, (void **)&m_pTextDocument);
-
ITextRange
在设置IM控件文本对齐、缩进等格式时,要用到ITextRange。需要注意的是,Range与Selection是不同的概念。Range并不会选择文本,它只是标记了一个范围;而Selection是真实的选中了一段文本,文本背景Highlight加亮显示。Selection是通过SetSel或鼠标框选。而Range是通过ITextDocument::Range方法。我们在代码中,对段落进行格式化,建议少使用SetSel,而应该用Range代替。比如用户使用鼠标,选中了IM中的一段内容,准备复制到剪贴板,但如果使用SetSel选中了其它一个段落准备设置它的段落格式,就会把用户选中的内容清掉,而Range就不会。
通过ITextRange::GetPoint方法,还可以得到指定cp的坐标(四个角的坐标)。但经过测试,只有在RichEdit可视范围内的cp,可以得到坐标。但高版本的RichEdit,支持额外的GetPoint参数(vs2008 MSDN中,并没有额外参数的介绍,在MSDN官网,可以查到),可以得到不可视范围内cp的坐标。还有一种方法,可以得到任意cp的左上角坐标,那就是PosFromChar(或GetCharPos,两者内部实现一样)。为了兼容性,建议采用PosFromChar来得到cp坐标。下面是得到cp坐标的详细介绍:
-
求RichEdit中指定cp的坐标,有几种方法:
1.使用PosFromChar或GetCharPos(两者内部都是调用EM_POSFROMCHAR),可以得到指定cp左上角坐标。通过指定cp的下一个cp和下一行cp,可以得到指定cp的完整坐标(注意,当整个RichEdit就一个字符时,仍然可以通过PosFromChar(1),得到这个字符右上角坐标,虽然并没有cp为1的字符)
2.通过使用FormatRange,可以模拟输出,从而得到所需要的高度坐标(宽度自己指定)
3.通过ITextRange::GetPoint,可以得到选中范围完整坐标。它与方法一在微软RichEdit内部都是由CDisplayML::PointFromTp实现。不过使用这个接口有个注意事项,在VS2008sp1本地MSDN介绍中,ITextRange::GetPoint第一个参数只有tomStart或tomEnd与TA_TOP、TA_BASELINE、TA_BOTTOM或TA_LEFT、TA_CENTER、TA_RIGHT的组合。但这样只能得到可视范围内cp的坐标。当cp滚动到可见范围外后,得到的是0。
不过。在线的MSDN上,提供了一些额外的参数,可以得到可视范围外的坐标,所有参数如下:
None 0 无选项。
IncludeInset 1 将左侧和顶部嵌入添加到矩形的左侧和顶部坐标,并从坐标的右侧和底部除去右侧和底部嵌入。
Start 32 文本范围的开始位置。
ClientCoordinates 256 返回工作区坐标而不是屏幕坐标。
AllowOffClient 512 允许工作区之外的点。
Transform 1024 使用宿主应用程序提供的世界转换的变换坐标。
NoHorizontalScroll 65536 水平滚动已禁用。
NoVerticalScroll 262144 垂直滚动已禁用。
所以ITextRange::GetPoint的第一个参数,只要或上512,就可以得到可视区外的坐标了。不过。这些新的属性,不支持旧版本的RichEdit.
-
ITextPara
这个接口,是用来设置指定Range的格式的,包括对齐、缩进、增加行高等。在制作IM控件时,在气泡模式中,对于自己发送的消息,要显示到RichEdit右侧。这个效果,就可以通过对指定Range,调用ITextPara接口,来设置左缩进完成。
ITextPara接口,由ITextRange:: GetPara返回:
CComPtr<ITextPara> pTextPara = NULL;
pTextRange->GetPara(&pTextPara);
pTextPara->SetIndents(0,GetXFPPTSFromPixel(50), 0);
pTextRange->SetPara(pTextPara);
这样,就会把Range所在段落的左缩进,设置为50像素。
在使用ITextPara接口时,常常会碰到一个新的坐标单位:floating-point points(简称FPPTS),1 FPPTS等于20分之1缇(twips),而缇与像素的换算关系为:
X缇等于像素乘以1440再除以DPI值。
所以,FPPTS与像素的换算关系为:
X FPPTS等于像素乘以72再除以DPI值。
-
二、防闪烁
创建一个RichEdit控件,在父窗口缩放带着RichEdit一起缩放时,会明显感觉到内容在闪烁。我们采用常规的防闪烁方案(clip children, WM_ERASEBKGROUD, SetRedraw等)似乎作用不大。由于RichEdit内部是通过ITextServices::TxDraw绘制,所以,我们把RichEdit设置为透明(加上WS_EX_TRANSPARENT扩展风格),然后直接调用TxDraw,把内容画到父窗口上。
这里有很多注意事项。下面分别讲述:
-
要处理WM_PAINT消息,直接return 0:
LRESULT CSkinRichEditCtrl::WindowProc(UINTmessage, WPARAM wParam, LPARAM lParam)
{
if(message == WM_PAINT)
{
//CPaintDC dc(this);不能删除,否则会导致不停的收到WM_PAINT消息
CPaintDCdc(this);
return0;
}
elseif (message == WM_ERASEBKGND)
{
returnTRUE;
}
returnCRichEditCtrl::WindowProc(message, wParam, lParam);
}
-
TxDraw并不是把WM_PAINT的内容显示到另外的DC上。TxDraw与WM_PAINT是独立的。RichEdit本身在WM_PAINT中,通过TxDraw绘制到RichEdit上。但你仍然可以自己调用TxDraw,指定与RichEdit不同的宽度,绘制到另外的DC上,两份绘制,呈现的结果是不同的,由于两份绘制传入的宽度不同,会导致显示出来的折行位置不同。并且,即使是自己调用TxDraw,,似乎也会引起滚动位置的重新计算。所以,为了让自己调用的TxDraw显示的内容,与RichEdit内部显示的内容完全吻合。TxDraw的坐标必须传客户区坐标(一定不要传非客户区坐标)。
-
为RichEdit设置段落格式,一定要在WM_SIZE中处理,不能为了性能考虑,放到绘制的时候。也不能放到WM_VSCROLL中。这是因为,调用pTextRange->SetPara之类的方法设置文本格式,会导致重新计算滚动信息。比如重新设置了行高、设置了缩进等。重新计算滚动信息后,又会导致刷新,这样,在滚动RichEdit时,可能导致按行滚动,而不是按像素滚动。也可能上下跳动。所以,对于RichEdit格式的设置,全部放到插入数据时和WM_SIZE中。
三、气泡
当代的IM聊天控件,大多支持气泡模式。很多人可能认为,气泡、头像应该都是OLE对象。也想过气泡、头像这些元素,可能是画到父窗口上的,作为RichEdit背景透上来的,然后处理RichEdit缩放、滚动等消息,让它们与消息内容连动。大部分人想过之后,会马上下意识的Pass掉方案2,直观认为这种技巧性的方案,不是解决问题的常规方案。然后沿着方案1这个思路往下研究,会发现根本走不通。其实最终解决问题的方案,正是方案2。
在制作IM软件聊天记录时,对方发送的消息,永远显示到RichEdit左侧,且左对齐。而自己发送的消息,分两种情况:
1.当RichEdit宽度小于某个值时,自己发送的消息,显示到右侧。
2.当RichEdit宽度大于某个值时,自己发送的消息,显示到左侧,与别人发送的消息一样显示。
不论哪种情况,消息内容,在气泡内都是左对齐的(观察一条消息包含多个段落)。由于消息内容永远是左对齐,当自己发送的消息显示到右侧时,可以通过设置消息段落格式的左缩进,让消息显示到右侧。
为了在左右两边显示头像,需要把文字往中间挤,这可以通过调用SetRect为RichEdit指定显示区域,为左右两侧留出一些空间。如下图:
我们为RichEdit左右各设置40像素的边距,这样,文本就被限制在上图左右红色垂直线之间显示了。因为图像不是RichEdit的内容,而是画到RichEdit父窗口背景上的,所以,这样设置,并不影响头像画到左右缩进线之外。
不过这种方式,消息发送者和消息内容,是对齐的,观察QQ的IM控件,是不对齐的,所以为了以后灵活布局,采用按段落,逐条消息分开设置的方案。
在第一章中提到,设置RichEdit的段落格式,一定要放到插入数据时和WM_SIZE中。而获取气泡、头像的坐标,放到显示的时候,这时候,拿到的坐标才是最精确的。在获取气泡、头像坐标时,一定千万不要再设置段落格式。
3.1内容布局
所谓的内容布局,仅指对消息发送者设置对齐方式和对消息内容进行左缩进设置两项。
对于别人发的消息,永远在RichEdit左侧,只需要对消息发送者和消息内容,进行左缩进设置,以留出头像空间。
对于自己发的消息,根据是否是气泡模式,且RichEdit的宽度是否大于某个设置的值,会有显示在左侧和右侧两种可能。当显示在左侧时,与别人发的消息同等对待;当显示在右侧时,需要把消息发送者设置成右对齐、为消息内容设置左缩进,从而把消息挤到右侧。
插入时初始布局
为了使消息初始显示的时候,就有正确的布局,需要在插入的时候,就指定好消息的格式。
对内容布局,主要是通过ITextPara这个接口。在第一章中有详细介绍。
为了布局内容,及计算头像气泡的坐标,需要在插入消息的时候,记录消息插入RichEdit后的一些字符的位置cp。首先,要记录整个消息(从发送者开始)的起始cp,这个cp是相对于整个RichEdit的绝对m_nMsgStartCp。然后记录消息正文开始相对于m_nMsgStartCp的cp: m_nMsgContentStartCp。最近记录消息结束位置相对m_nMsgStartCp的结束cp:m_nMsgEndCp。整个消息,只有起始cp是绝对cp。这是因为,IM控件有个功能,就是滚动到最上端后,有个“查看更多消息”。点击之后,会在原有消息之前,再插入一批新的消息,这时候,原有的消息中记录的cp,都要更新。采用上述方法,只更新起始cp即可。
运行时动态布局
在之后运行过程中,当RichEdit尺寸变化后,消息需重新布局。为了减小各种莫名其妙的问题产生,我们只在WM_SIZE中,对消息进行重新布局。
对于别人发送的消息,由于永远在左侧显示,在插入的时候,已指定了格式,所以,在插入消息后的任何时间,都不需要再重新设置。所以,所谓的运行时动态布局,仅布局自己发送的消息。
由于在RichEdit尺寸发生变化时,不可见的消息内容,也可能发生折行位置cp的改变。所以,需要对所有自己发送的消息,进行格式重置。这需要进行两次循环。
第一次,把所有自己发送的消息的左缩进,重置成消息在左侧显示时的左缩进值。第二次循环,根据当前的设置,计算自己发送的消息占用的宽度,结合RichEdit宽度,计算出消息的左缩进值。
3.2头像、气泡坐标获取
下面讲解得到气泡坐标的算法:
一条消息,可能包含一个或多个段落(以\n分隔),每个段落的文本,也可能很长,也可能很短。比如某条消息,只有一个很短的段落,一行即可显示完整。这样,我们通过《RichEdit杂项.txt》中的方法,很容易计算这段文本的RECT。而如果另一条消息的文本很长,就需要换行显示,如果这条消息还包含多个段落,计算起来就更加复杂了。如果这条消息我们自己发送的,需要显示到RichEdit右侧,那就难上加难了。
我们一步步分析,把复杂问题分解成简单可解的问题。
别人发送的消息,永远显示在左侧,自己发送的消息,根据RichEdit的宽度,可能显示在左侧或右侧。当显示到左侧时,与别人发送的消息同样处理。所以,我们分消息显示到左侧和右侧,分别处理。
消息显示到左侧
我们如何判断所有段落都能在一行显示(即有N个段落,显示N行)完,还是某个或某些段落有换行呢?在插入消息的时候,我们可以查找\n的数量,这个数量+1,就是消息的段落数。把这个段落数,记录到消息结构体CIMMsg的m_nMsgParagraphCount成员中。在插入消息的时候,也把消息文本起止位置的cp(char position)分别记录到CIMMsg的m_nMsgContentStartCp和m_nMsgEndCp成员中。这样,通过LineFromChar就可以得到消息起止字符所在的行的索引。通过行索引差值与m_nMsgParagraphCount比较,就可以知道,是否有某个(些)段落有换行。
某个段落有换行
当某个段落有换行时,情况变得比较容易了。因为既然有换行,说明某一行的文字宽度是占满RichEdit文字可显示宽度的,即RichEdit的宽度减左右缩进。这样,我们就得到了气泡坐标的left和right值:
rcQiPao.left =rcInset.left; // rcInset是RichEdit的文本缩进
rcQiPao.right =rcClient.right – rcInset.right; // rcClient是RichEdit的坐标
我们只需要得到消息占用的高度即可。通过GetCharPos,可以得到消息第一个字符的左上角坐标,我们只用它的上坐标:
rcQiPao.top =ptFirstChar.y; // ptFirstChar是通过GetCharPos得到的第一个字符的坐标
得到最后一个字符的bottom坐标,方法有些不同。可以通过ITextRange来实现,详见《RichEdit杂项.txt》。
-
通过最后一个字符的cp,得到其所在的行
-
通过LineIndex得到此行第一个字符的cp
-
通过LineLength得到此行的文本长度,从而得到此行最后一个字符的cp
-
通过ITextDocument::Range,选中最后一行,返回ITextRange
-
通过ITextRange::GetPoint(tomEnd| TA_BOTTOM | TA_RIGHT, &lXEnd, &lYEnd);得到最后一个字符的相对于屏幕的右下坐标CPoint(lXEnd,lYEnd)。
-
通过调用RichEdit的ScreenToClient,得到相对于RichEdit的坐标。
这样,气泡的坐标就得到了。
当所有段落都能在一行显示(即有N个段落,显示N行)
当所有段落都能在一行显示时,要分别计算每行的rect。然后计算所有rect最大外切矩形。这个外切矩形,就是气泡的坐标。
求每行的rect方法与上面“某个段落有换行”里介绍的一样。即上面的步骤b-f。可以把它封装成一个接口:
int CWLRichEditCtrl::GetLineRect(intnLineIndex, LPRECT lprcLine, LINE_RECT eLineRect)
消息显示到右侧
某个段落有换行
当某个(些)段落有换行时,情况和显示到左侧完全相同。
当所有段落都能在一行显示(即有N个段落,显示N行)
-
先按显示到左侧处理,求到气泡的Rect。
-
然后用rcClient.Widht() – rcInset.left – rcInset.right – rcQiPao.Width(),求得气泡需要的左缩进量。
杂记:
-
当自己发送的消息,需要显示到RichEdit右侧,在计算气泡坐标时。要先设置这条消息的段落格式,左缩进设置为0,计算所需要的宽度和高度。如果所需要的宽度,小于RichEdit的宽度,则重新设置段落的左缩进为“RichEdit的宽度减需要的宽度”,达到气泡右侧显示的目的。这里有个注意事项:设置段落格式,可以通过SetParaFormat。前提是要使用SetSel,选中所要设置的段落,但这会导致两个问题:第一个是如果你已经用鼠标选中了RichEdit中的某部分内容,在调用SetSel时,会把你选中的状态清掉。二是在RichEdit窗口左右缩放时,会导致严重的闪烁问题,并且会看到你发的消息,在RichEdit中,左右来回显示(这是因为你一会设置左缩进为0,一会又设置成另一个值)。解决这两个问题的方法是:不使用SetParaFormat,而是使用ITextPara:
CComPtr<ITextDocument> pTextDocument =NULL;
IMRE_CALL_FUN_RETURN(pTextDocument,GetITextDocument());
ITextRange *pTextRange = NULL;
pTextDocument->Range(pMsg->GetMsgContentStart(),pMsg->GetMsgEnd(), &pTextRange);
#ifdef _DEBUG
BSTR bstr;
pTextRange->GetText(&bstr);
#endif // _DEBUG
CComPtr<ITextPara> pTextPara = NULL;
pTextRange->GetPara(&pTextPara);
pTextPara->SetIndents(0, 0, 0);
pTextRange->SetPara(pTextPara);
这里,SetIndents的单位是floating-pointpoints,类型为float,可以为正值,也可以为负值。Floating-point points单位与缇的换算公式为:
#defineTWIPS_TO_FPPTS(x) (((float)(x)) * (float)0.05)
而像素与缇的换算公式为:
intLibUIDK::GetXTwipsFromPixel(int nPixel)
{
HDC hDCN =::GetDC(::GetDesktopWindow());
int nXDPI =GetDeviceCaps(hDCN, LOGPIXELSX);
::ReleaseDC(::GetDesktopWindow(),hDCN);
if (nXDPI == 0)
{
nXDPI = 96;
}
int nRet = MulDiv(nPixel,1440, nXDPI);
return nRet;
}
所以,像素与floating-point points的换算公式为:
// 1 FPPTS equalto 1/20 twips
floatLibUIDK::GetXFPPTSFromPixel(int nPixel)
{
HDC hDCN =::GetDC(::GetDesktopWindow());
int nXDPI =GetDeviceCaps(hDCN, LOGPIXELSX);
::ReleaseDC(::GetDesktopWindow(),hDCN);
if (nXDPI == 0)
{
nXDPI = 96;
}
float nRet =(float)MulDiv(nPixel, 72, nXDPI); // 72 = 1440 * 0.05
return nRet;
}
只要设置SetIndents的中间参数,就可以设置左缩进。
四、往RichEdit中插入格式化文本
插入格式化文本,有两个重要的结构体:CHARFORMA2T和PARAFORMAT2。前者设置字符格式,后者设置段落格式。
typedef struct _charformat2 {
UINT cbSize; //必须被初始化为sizeof(CHARFORMA2T)
DWORD dwMask;
DWORD dwEffects;
LONG yHeight;
LONG yOffset;
COLORREF crTextColor;
BYTE bCharSet;
BYTE bPitchAndFamily;
TCHAR szFaceName[LF_FACESIZE];
WORD wWeight;
SHORT sSpacing;
COLORREF crBackColor;
LCID lcid;
DWORD dwReserved;
SHORT sStyle;
WORD wKerning;
BYTE bUnderlineType;
BYTE bAnimation;
BYTE bRevAuthor;
BYTE bReserved1;
} CHARFORMAT2;
CHARFORMAT2中的yHeight、yOffset和sSpacing的单位是twips(缇)。1缇等于1/1440英寸。而1英寸等于DPI像素。DPI的值随操作系统的设置不同而不同,默认是96。它的值可以通过GetDeviceCaps得到,分水平DPI值和垂直DPI值:
HDChDCN = ::GetDC(::GetDesktopWindow());
intnXDPI = GetDeviceCaps(hDCN, LOGPIXELSX);
intnYDPI = GetDeviceCaps(hDCN, LOGPIXELSY);
::ReleaseDC(::GetDesktopWindow(),hDCN);
所以给定像素,得到对应多少缇,可以封装成下面的接口:
// 1 twips equal to 1/1440 inch.
int LibUIDK::GetXTwipsFromPixel(int nPixel)
{
HDChDCN = ::GetDC(::GetDesktopWindow());
intnXDPI = GetDeviceCaps(hDCN, LOGPIXELSX);
::ReleaseDC(::GetDesktopWindow(),hDCN);
intnRet = nPixel * 1440 / nXDPI;
returnnRet;
}
int LibUIDK::GetYTwipsFromPixel(int nPixel)
{
HDChDCN = ::GetDC(::GetDesktopWindow());
intnYDPI = GetDeviceCaps(hDCN, LOGPIXELSY);
::ReleaseDC(::GetDesktopWindow(),hDCN);
intnRet = nPixel * 1440 / nYDPI;
returnnRet;
}