以金山界面库(openkui)为例思考和分析界面库的设计和实现——资源读取模块分析
? ? ? ? 按照軟件的執行流程,我們首先遇到《以金山界面庫(openkui)為例思考和分析界面庫的設計和實現——問題》中提出的最后一個問題:界面描述文件的放置位置。我們曾提出一種方案:將界面描述文件打包后放在資源文件中;在使用時,解析并讀取資源文件。實際上Kui也是按照我們這個思路在做的,只是做得比我們要精巧。在閱讀這部分代碼的過程中,我發現其存在一定的編碼缺陷以及設計缺陷。我會在文中適時指出問題并提出修正及改進的方案。(轉載請指明出于breaksoftware的csdn博客)
? ? ? ? 為了表述方便,我們將以KUI自帶的例子工程Sample1為例。在該項目的res目錄下,我們看到一個名字為sample1.kui的文件。
? ? ? ? 在Sample1工程的資源文件中,上圖中sample1.kui將作為一個類型為“SKIN”,名字為“KUIRED.DAT”的資源。
? ? ? ? 從這個特殊的后綴名.kui可以猜測出,這個文件是一個壓縮文件。
? ? ? ??這樣,我們心里有了底,同時為我們閱讀Kui的資源管理代碼提供了視覺上的參考。
? ? ? ? 在openkui\KUILib\kscbase\src下有個文件kscres.cpp。它定義了資源文件處理邏輯。
? ? ? ? 首先,我們查看這段代碼
KAppRes& KAppRes::Instance()
{static KAppRes _singleton;return _singleton;
}
? ? ? ??可以看出,這是個單例類。因為界面描述數據只需要讀取和解析一次,所以這兒設計成單例類。以后使用它的地方,就不用重復讀取和解析了。
? ? ? ? 我們再看下作為私有函數的構造函數,它顯示該類執行的脈絡
KAppRes::KAppRes() : m_hTempRes(INVALID_HANDLE_VALUE)
{PrepareRes();OpenResPack();LoadStringRes();LoadImageRes();LoadXmlRes();LoadFontRes();
}
? ? ? ??粗略看了函數名。可以得出如下流程
? ? ? ??除了“讀取String”、“讀取Image”和“讀取字體”資源外,我們可能比較難以猜測到其他過程做了什么。如果按照我前一篇的思路,“預處理資源文件”可能對應于“讀取指定資源”,“打開資源文件”可能對應于“將壓縮包文件解壓”,是不是如此呢?我們拭目以待。在解讀之后的代碼之前,我有個疑問,這些操作如果有一步沒有成功,還有必要繼續往下走么?怎么就沒一個判斷?放下這個問題,我們看之后的代碼。
? ? ? ??我們先看
bool KAppRes::PrepareRes()
{bool retval = false;KFilePath pathRes = KFilePath::GetFilePath(g_hInstance);HRSRC hResInfo = NULL;HGLOBAL hResDat = NULL;PVOID pResBuffer = NULL;DWORD dwResBuffer;wchar_t szTempPath[MAX_PATH] = { 0 };wchar_t szTempFilePath[MAX_PATH] = { 0 };pathRes.RemoveExtension();pathRes.AddExtension(L"kui");if (GetFileAttributesW(pathRes) != INVALID_FILE_ATTRIBUTES){m_strResPackPath = pathRes.value();}else{hResInfo = FindResourceW(_ModulePtr->GetResourceInstance(), L"kuires.dat", L"SKIN");if (!hResInfo)goto clean0;hResDat = LoadResource(_ModulePtr->GetResourceInstance(), hResInfo);if (!hResDat)goto clean0;pResBuffer = LockResource(hResDat);if (!pResBuffer)goto clean0;dwResBuffer = SizeofResource(_ModulePtr->GetResourceInstance(), hResInfo);m_memZipRes.SetData(pResBuffer, dwResBuffer);}retval = true;clean0:return retval;
}
? ? ? ??到12行,都是在Exe文件所在目錄拼接出與Exe文件同名,但是后綴為kui的資源文件。比如我的電腦上,調試文件目錄是D:\快盤\Code Project\openkui\Samples\Sample1\Debug\Sample1.exe,得到的pathRes對應的目錄是D:\快盤\Code Project\openkui\Samples\Sample1\Debug\Sample1.kui。如果該資源文件獨立存在于Exe目錄下,則使用該文件做后續操作。如果該文件不存在,則從PE文件資源中,讀取出類型為“SKIN”、名字為“kuires.data”的資源,并保存在memZipRes(一段內存中)中。
? ? ? ??這個流程,我們可以看出來,其大體思路和我之前猜測的一致,只是它增加了優先對獨立的壓縮包資源文件的處理。于是我們可以得出:Kui的界面描述文件,可以放在:
? ? ? ? 1 Exe文件所在的目錄下,名字和Exe相同的、后綴為kui的文件(以后簡稱界面文件包)中
? ? ? ? 2 PE文件資源類型為“SKIN”、名字為“kuires.dat”的資源(以后簡稱界面內存塊)中
? ? ? ? 其中1的優先級要高于2。
? ? ? ? 這種設計方案還是很有意思的。因為這個流程可以實現換膚功能。比如我們下載了A.kui、B.kui、C.kui和D.kui四套皮膚。如果用戶選擇了A皮膚,則我們可以將A.kui拷貝到Exe所在目錄,并將其命名為與Exe同名、后綴為kui的名字。這樣就實現了換膚。即使這套外置皮膚壞了,或者被刪了,我們還可以使用資源中的那套皮膚。
? ? ? ? 雖然想法很好,但是代碼中的邏輯卻存在一定的編碼缺陷和設計缺陷,我們先說編碼缺陷:
if (GetFileAttributesW(pathRes) != INVALID_FILE_ATTRIBUTES){m_strResPackPath = pathRes.value();}
? ? ? ??這步,可以用來判斷一個文件是否存在么?其實不可以。因為如果我新建一個與壓縮包同名的“文件夾”,GetFileAttributesW將返回FILE_ATTRIBUTE_DIRECTORY,這將導致這個錯誤的邏輯認為該文件夾是一個壓縮文件,從而導致之后的邏輯出現處理異常。該函數應該寫成
if ( PathFileExists(pathRes) && 0 == ( GetFileAttributesW(pathRes) & FILE_ATTRIBUTE_DIRECTORY ) ){m_strResPackPath = pathRes.value();}
? ? ? ??其中
還有個設計缺陷。假如我們是使用這個庫的開發者,我們在調試過程中,難免會修改界面描述文件。那么難道我們每修改一次,都要將描述文件壓縮成一個包么?這樣不是很難調用?我覺得,可以在PrepareRes函數中,
新增一段對debug情況的處理:在debug情況下我們應該獲取工程res目錄下一個特定的文件夾,該文件夾保存了未壓縮的各個文件。這樣我們就可以不用每次修改資源后都要打個資源包了。
? ? ? ? 我們在KAppRes類私有成員中增加
#ifdef DEBUG// 保存debug環境下界面描述文件文件夾目錄std::wstring m_strResFloderPath;
#endif
? ? ? ??在PrepareRes的pathRes.RemoveExtension();之前新增
#ifdef DEBUGpathRes.RemoveFileSpec();pathRes.RemoveFileSpec();pathRes.AddBackslash();pathRes.Append(L"res");pathRes.AddBackslash();pathRes.Append(L"skin");pathRes.AddBackslash();if ( PathFileExists(pathRes) && GetFileAttributesW(pathRes) & FILE_ATTRIBUTE_DIRECTORY ){m_strResFloderPath = pathRes.value();return true;}else{_ASSERT_EXPR(FALSE, L"Debug環境下要求res目錄下skin目錄保存界面描述文件");return false;}
#endif
? ? ? ??這樣我們將方便我們調試工作。
? ? ? ? 接下來我們看OpenResPack這個函數。在PrepareRes中,我們可能會得到界面文件包或者界面內存塊。OpenResPack將先后嘗試從這兩個位置獲取界面信息。在這個函數中,我們將看到,如何使用開源的Zlib代碼去獲取壓縮包(內存)中文件的信息。
bool KAppRes::OpenResPack()
{bool retval = false;zlib_filefunc_def zip_funcs;std::string strPathAnsi;int nRetCode;HRSRC hResInfo = NULL;HGLOBAL hResDat = NULL;PVOID pResBuffer = NULL;DWORD dwResBuffer = 0;fill_win32_filefunc(&zip_funcs);strPathAnsi = UnicodeToAnsi(m_strResPackPath);m_pResPackData = unzOpen2(strPathAnsi.c_str(), &zip_funcs);if (m_pResPackData)goto UNZRESPACKDATA;
? ? ? ? 這段代碼是嘗試預處理界面文件包。我們注意下
這兒使用了fill_win32_filefunc填充了zlib_filefunc_def結構體,還要注意下我們對unzOpen2傳入了界面文件包的路徑。我們接著看,預處理之后的流程
UNZRESPACKDATA:nRetCode = unzGoToFirstFile(m_pResPackData);while (UNZ_OK == nRetCode){char szCurrentFile[260];unz_file_info fileInfo;uLong dwSeekPos;uLong dwSize;nRetCode = unzGetCurrentFileInfo(m_pResPackData, &fileInfo, szCurrentFile, sizeof(szCurrentFile), NULL, 0, NULL, 0);if (nRetCode != UNZ_OK)goto clean0;dwSeekPos = unzGetOffset(m_pResPackData);dwSize = fileInfo.uncompressed_size;m_mapResOffset.insert(KResOffset::value_type(szCurrentFile, KResInfo(dwSeekPos, dwSize)));nRetCode = unzGoToNextFile(m_pResPackData);}
? ? ? ??這段代碼,大致可以看出來,這種遍歷方式和VC中遍歷文件的一種方法——FindFirstFile、FindNextFile很相似。
? ? ? ??如此,便將壓縮包中的文件信息保存到Map結構體對象m_mapResOffset中。其中信息包括文件的相對目錄,文件的相對偏移和大小。
? ? ? ? 有了這組信息,我們之后讀取單個文件,將變得非常方便了。
? ? ? ? 以上我們討論了如何使用Zlib獲取界面壓縮包中文件信息的方法。現在我們再看下如何使用Zlib從界面內存塊中獲取壓縮后的文件信息。
? ? ? ??是否還記得,之前我著重提到一點“使用了fill_win32_filefunc填充了zlib_filefunc_def結構體”。之所以著重,是因為我們現在解析界面內存塊的信息時,將要自己填充zlib_filefunc_def結構體中各個回調函數。我們先看fill_win32_filefunc內部的實現
void fill_win32_filefunc (pzlib_filefunc_def)zlib_filefunc_def* pzlib_filefunc_def;
{pzlib_filefunc_def->zopen_file = win32_open_file_func;pzlib_filefunc_def->zread_file = win32_read_file_func;pzlib_filefunc_def->zwrite_file = win32_write_file_func;pzlib_filefunc_def->ztell_file = win32_tell_file_func;pzlib_filefunc_def->zseek_file = win32_seek_file_func;pzlib_filefunc_def->zclose_file = win32_close_file_func;pzlib_filefunc_def->zerror_file = win32_error_file_func;pzlib_filefunc_def->opaque=NULL;
}
? ? ? ??可以見得,它傳遞了“打開文件”、“讀取文件”、“寫入文件”、“移動讀標識”和“關閉文件”等操作的函數地址。我粗略看下這些函數的實現,它們只是對CreateFile、ReadFile和WriteFile等文件操作的封裝。對應的,對于不在磁盤上的文件,我們可以封裝相應的操作內存的函數,然后將這些函數地址傳遞給該結構體對象。
zip_funcs.zopen_file = ZipOpenFunc;
zip_funcs.zread_file = ZipReadFunc;
zip_funcs.zwrite_file = ZipWriteFunc;
zip_funcs.ztell_file = ZipTellFunc;
zip_funcs.zseek_file = ZipSeekFunc;
zip_funcs.zclose_file = ZipCloseFunc;
zip_funcs.zerror_file = ZipErrorFunc;
zip_funcs.opaque=NULL;
m_pResPackData = unzOpen2((const char*)&m_memZipRes, &zip_funcs);if (!m_pResPackData)goto clean0;
? ? ? ??我們
注意下unzOpen2函數,該函數在聲明時指明其是一個文件路徑,而我們卻將資源的內存塊首地址傳遞進去了。那么unzOpen2可以正確處理么?我們看下ZipOpenFunc函數的實現,就知道這個問題是如何巧妙的解決掉的。
void* ZipOpenFunc(void* opaque, const char* filename, int mode)
{return (void*)filename;
}
? ? ? ??看,它直接將filename返回了。可以想象ZipOpenFunc就是為了打開文件,并定位到首地址。既然傳進來的就是內存塊首地址,那么直接返回之就行了。而其他函數的實現,也是很簡單的,和操作文件一樣。比如
long ZipSeekFunc (void* opaque, void* stream, uLong offset, int origin)
{uLong ret = -1;CMemFile* pMemFile = (CMemFile*)stream;DWORD dwRetCode;if (!pMemFile)goto clean0;dwRetCode = pMemFile->SetFilePointer(offset, NULL, origin);if (INVALID_SET_FILE_POINTER == dwRetCode)goto clean0;ret = 0;clean0:return ret;
}
? ? ? ??在調用解析界面內存塊的函數前。OpenResPack還多了一個判斷:判斷已讀取的m_memZipRes是否為空,如果為空,則再從資源文件中讀取界面描述塊到內存中。
if (strlen((const char*)&m_memZipRes) == 0)
{//防止.kui格式錯誤導致unzOpen2返回空的m_pResPackDatahResInfo = FindResourceW(_ModulePtr->GetResourceInstance(), L"kuires.dat", L"SKIN");if (!hResInfo)goto clean0;hResDat = LoadResource(_ModulePtr->GetResourceInstance(), hResInfo);if (!hResDat)goto clean0; pResBuffer = LockResource(hResDat);if (!pResBuffer)goto clean0;dwResBuffer = SizeofResource(_ModulePtr->GetResourceInstance(), hResInfo);m_memZipRes.SetData(pResBuffer, dwResBuffer);
}
? ? ? ??
這個代碼一開始判斷m_memZipRes是否為空,存在一定的漏洞:假如資源文件的第一個字符就是\0,則就會認為這段讀取的數據為空了。當然,一般不存在這樣的問題,因為目前壓縮包文件的第一個字符肯定不是\0。但是從代碼的嚴謹性上來說,應該給openkui\KUILib\Include\kscbase下kscmemfile.h中的CMemFile新增一個共有函數
BOOL IsEmpty()
{return m_buffer.GetCount() == 0 ? TRUE : FALSE;
}
? ? ? ??然后那個判斷應該改成
If( m_memZipRes.IsEmpty())
{
……
}
? ? ? ??還有,這個if中的邏輯PrepareRes中讀取資源邏輯一樣。應該將其提煉出來,這樣可以不會讓代碼看著十分冗余。我在之后附加的工程中,會將這個函數提煉到一個名字為 GetResInResfile的函數中。
? ? ? ? 我們接著看之后對數據的讀取和保存。
LoadStringRes();
LoadImageRes();
LoadXmlRes();
LoadFontRes();
? ? ? ??中前三個函數對應于
? ? ? ??KUI提供的例子中,都沒有LoadFontRes對應的fonts.xml文件存在。所以我們可以先忽略字體處理這塊邏輯。
? ? ? ? 我們以LoadXmlRes為例,講解其執行過程。
bool KAppRes::LoadXmlRes()
{bool retval = false;void* pBuffer = NULL;unsigned long dwBuffer = 0;TiXmlDocument xmlDoc;const TiXmlElement* pXmlChild = NULL;const TiXmlElement* pXmlItem = NULL;if (!GetRawDataFromRes("xmls.xml", &pBuffer, dwBuffer))goto clean0;if (!xmlDoc.LoadBuffer((char*)pBuffer, (long)dwBuffer, TIXML_ENCODING_UTF8))goto clean0;pXmlChild = xmlDoc.FirstChildElement("xmls");if (!pXmlChild)goto clean0;pXmlItem = pXmlChild->FirstChildElement("xml");while (pXmlItem) {std::string strId;std::string strPath;strId = pXmlItem->Attribute("id");strPath = pXmlItem->Attribute("path");if (strId.length() && strPath.length()){m_mapXmlTable[strId] = strPath;}pXmlItem = pXmlItem->NextSiblingElement("xml");}retval = true;clean0:if (pBuffer){FreeRawData(pBuffer);}return retval;}? ? ? ? ?第10行的GetRawDataFromRes是我們特別需要注意的一個函數。該函數傳入一個文件相對路徑、用于保存該文件內容的內存塊首地址和該內存塊的大小。
bool KAppRes::GetRawDataFromRes(const std::string& strId, void** ppBuffer, unsigned long& dwSize)
{bool retval = false;KResStore::iterator store;KResOffset::iterator offset;unsigned long dwOffset;int nRetCode;if (!ppBuffer)goto clean0;offset = m_mapResOffset.find(strId);if (offset == m_mapResOffset.end())goto clean0;dwOffset = offset->second.first;dwSize = offset->second.second;*ppBuffer = new unsigned char[dwSize+1];if (!*ppBuffer)goto clean0;nRetCode = unzSetOffset(m_pResPackData, dwOffset);if (nRetCode != UNZ_OK)goto clean0;nRetCode = unzOpenCurrentFile(m_pResPackData);if (nRetCode != UNZ_OK)goto clean0;nRetCode = unzReadCurrentFile(m_pResPackData, *ppBuffer, dwSize);if (0 == nRetCode)goto clean0;retval = true;clean0:if (!retval){if (ppBuffer){if (*ppBuffer){delete[] (*ppBuffer);*ppBuffer = NULL;}}}return retval;
}
? ? ? ? ?該函數先在保存文件信息的map中尋找傳入的相對路徑對應的文件信息,然后動態分配一段大小合適的空間(如果成功,則在函數外部釋放,否則在函數內部釋放),再使用unzSetOffset將壓縮包讀取位置設置到相應的偏移處,通過unzReadCurrentFile將指定文件讀到內存中。是否還記得,我曾提出,這個庫在設計時存在一定的缺陷:沒有考慮debug情況下會經常修改界面文件的問題。我們之前在PrepareRes函數中獲取了保存界面描述文件(非壓縮)的路徑。這樣,我們可以對該函數做段修改,入參都不用改,我們只是讓該函數讀取指定文件的內容。
#ifdef DEBUGif ( ReadResFile(strId, ppBuffer, dwSize) ) {return true;}else {// _ASSERT_EXPR(FALSE, L"debug下從界面描述目錄讀取文件失敗");return false;}
#endif
? ? ? ??我封裝了一個讀取文件的函數ReadResFile
#define NEWBUFFERSIZE 0x100bool KAppRes::ReadResFile( const std::string& strId,void** ppBuffer, unsigned long& dwSize )
{std::string strFilePath = CW2A(m_strResFloderPath.c_str());strFilePath += strId;HANDLE hFile = CreateFileA(strFilePath.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL );if ( NULL == hFile ) {return false;}// 先分配讀取的數據空間DWORD dwTotalSize = NEWBUFFERSIZE; // 總空間char* pchReadBuffer = new char[dwTotalSize];memset(pchReadBuffer, 0, NEWBUFFERSIZE);DWORD dwFreeSize = dwTotalSize; // 閑置空間bool bSuc = false;do {char chTmpReadBuffer[NEWBUFFERSIZE] = {0};DWORD dwbytesRead = 0; // 用于控制讀取偏移OVERLAPPED Overlapped;memset(&Overlapped, 0, sizeof(OVERLAPPED) );while (true) { // 清空緩存memset(chTmpReadBuffer, 0, NEWBUFFERSIZE);// 讀取管道BOOL bRead = ReadFile( hFile, chTmpReadBuffer, NEWBUFFERSIZE, &dwbytesRead, &Overlapped );DWORD dwLastError = GetLastError();if ( bRead ) {if ( dwFreeSize >= dwbytesRead ) {// 空閑空間足夠的情況下,將讀取的信息拷貝到剩下的空間中memcpy_s( pchReadBuffer + Overlapped.Offset, dwFreeSize, chTmpReadBuffer, dwbytesRead );// 重新計算新空間的空閑空間dwFreeSize -= dwbytesRead;}else {// 計算要申請的空間大小DWORD dwAddSize = ( 1 + dwbytesRead / NEWBUFFERSIZE ) * NEWBUFFERSIZE;// 計算新空間大小DWORD dwNewTotalSize = dwTotalSize + dwAddSize;// 計算新空間的空閑大小dwFreeSize += dwAddSize;// 新分配合適大小的空間char* pTempBuffer = new char[dwNewTotalSize];// 清空新分配的空間memset( pTempBuffer, 0, dwNewTotalSize );// 將原空間數據拷貝過來memcpy_s( pTempBuffer, dwNewTotalSize, pchReadBuffer, dwTotalSize );// 保存新的空間大小dwTotalSize = dwNewTotalSize;// 將讀取的信息保存到新的空間中memcpy_s( pTempBuffer + Overlapped.Offset, dwFreeSize, chTmpReadBuffer, dwbytesRead );// 重新計算新空間的空閑空間dwFreeSize -= dwbytesRead;// 將原空間釋放掉delete [] pchReadBuffer;// 將原空間指針指向新空間地址pchReadBuffer = pTempBuffer;}// 讀取成功,則繼續讀取,設置偏移Overlapped.Offset += dwbytesRead;}else{if ( ERROR_HANDLE_EOF == dwLastError ) {bSuc = TRUE;}break;}}if ( bSuc ) {*ppBuffer = pchReadBuffer;dwSize = dwTotalSize - dwFreeSize;}else {if ( NULL != pchReadBuffer ) {delete [] pchReadBuffer;pchReadBuffer = NULL;}} } while (0);if ( NULL != hFile ) {CloseHandle(hFile);hFile = NULL;}return bSuc;
}
? ? ? ? 這樣,我們只要在res下新建一個skin文件夾,然后將我們的界面描述文件放在這個目錄下即可。
? ? ? ??我們看一下xmls.xml文件內容
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<xmls><xml id="IDR_KSC_SKIN" path="res/def_skin.xml" /><xml id="IDR_KSC_STYLE" path="res/def_style.xml" /><xml id="IDR_KSC_STRING" path="res/def_string.xml" /><xml id="IDR_DLG_MAIN" path="res/dlg_main.xml" />
</xmls>
? ? ? ??可以見到其中對應的文件是
? ? ? ??在使用KUI庫的程序中,我們將使用到這些id。
? ? ? ? 我們看下最終的讀取結果
? ? ? ??我們注意到res目錄下三個文件這個時候并沒有加載。為什么不加載,我們之后會在探索《以金山界面庫(openkui)為例思考和分析界面庫的設計和實現——問題》中“如何讀取保存界面元素屬性”問題時,對這個問題作出解釋。
? ? ? ? 總體來說,KUI這套資源管理邏輯存在以下問題:
? ? ? ? 1 部分代碼不嚴謹
? ? ? ? 2 設計缺乏對debug環境下的優化
? ? ? ? 3 讀取資源代碼容余,應該封裝下
bool KAppRes::GetResInResfile()
{bool retval = false;HRSRC hResInfo = NULL;HGLOBAL hResDat = NULL;PVOID pResBuffer = NULL;DWORD dwResBuffer;hResInfo = FindResourceW(_ModulePtr->GetResourceInstance(), L"kuires.dat", L"SKIN");if (!hResInfo)goto clean0;hResDat = LoadResource(_ModulePtr->GetResourceInstance(), hResInfo);if (!hResDat)goto clean0;pResBuffer = LockResource(hResDat);if (!pResBuffer)goto clean0;dwResBuffer = SizeofResource(_ModulePtr->GetResourceInstance(), hResInfo);m_memZipRes.SetData(pResBuffer, dwResBuffer);retval = true;
clean0:return retval;
}? ? ? ? 也有其出彩的地方:
? ? ? ? 1 CMemFile類的編寫
? ? ? ? 2 從內存中解壓文件
總結
以上是生活随笔為你收集整理的以金山界面库(openkui)为例思考和分析界面库的设计和实现——资源读取模块分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 以金山界面库(openkui)为例思考和
- 下一篇: 使用程序解决一道逻辑推理题