作者:劉樹偉
前段時間使用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;
}