简介:针对Windows平台下C++程序读取Excel表格的常见需求,尤其是在办公自动化场景中,这套VS2010工程源码完整演示了如何借助MFC框架调用WPS Office接口解析XLSX文件,适合需要绕过微软Excel组件、直接对接WPS能力的中高级C++开发者。包内共29个文件,压缩包约19.6MB,以头文件和源文件为主,其中包含WPS接口封装类、VS工程配置、调试日志、界面资源与示例表格,解压后可在VS2010中直接打开并跟踪调试。目前已有1787人学习下载,适合研究WPS二次开发或Open XML数据格式的读者参考。凭借这套工程,读者可以快速掌握动态加载WPS动态库、定位接口函数、遍历工作簿与工作表、读取单元格数据并回显到MFC控件等关键流程;同时工程中对文件打开失败、资源释放与多线程读取等问题的处理方式,也能为实际开发提供排错思路和可复用代码。
1. 为什么C++读Excel偏偏要走上WPS的COM自动化这条路
先把话说透:C++本身并没有任何原生的Excel读取能力,它只是一个语言,不懂xlsx的压缩包结构,也不认识WPS的私有格式。实际工程里,大家通常见到的方案有这么几类:
- 直接解析xlsx:把xlsx当压缩包打开,解析内部sheet XML。优点是没有依赖、速度快,缺点是碰到公式缓存、合并单元格、样式、富文本、日期序列号、数据验证、共享字符串表这些复杂结构时会很痛苦,尤其是WPS保存的文件在一些细节上和MS Office有微妙差异,踩坑之后你就明白了。
- 走ODBC/Jet驱动:用Microsoft Access Database Engine之类驱动把Excel当数据库查询。这个方案轻量但限制多,比如一次查询的行数上限、对列类型推断的不确定性(一列混着数字和文本时经常读到空值),而且版本和位数问题更是让人头疼。
- 调用COM自动化接口:让Excel/WPS自己把文件打开,再把数据交给你。这是最接近“眼见为实”的方案,也是唯一能真正复用WPS全部能力的路线——公式、图表、数据透视表、打印设置、宏行为统统不用你操心。
在国内办公环境里,WPS的覆盖率已经不用多说,很多企业内网电脑上根本没有微软Office。但你打开任务管理器会发现,这些电脑上几乎都躺着wps.exe、et.exe这些进程。所以作为C++程序员,如果能把WPS的自动化接口用起来,就等于把公司里所有装WPS的机器都变成了你程序的数据接口,这个诱惑力太大了。
我这次要说的,就是怎么用C++通过WPS表格的COM自动化接口,稳定地读出xlsx和et格式文件里的数据。整个过程不需要安装任何第三方库,用的全是Windows系统自带的COM组件和ATL封装,编译环境用Visual Studio或者Qt + MSVC都行。
2. 环境准备:先从WPS的初始化状态和版本谈起
2.1 分清Et.Application和KET.Application,这是很多代码跑不起来的根因
WPS表格的主程序可执行文件叫et.exe,但在COM注册表里的ProgID,不同版本有不同叫法:
| 版本情况 | ProgID | 对应进程 |
|---|---|---|
| WPS Office 2019/2023个人版、专业版 | Et.Application | et.exe |
| 较老的WPS 2013/2016某些分支 | KET.Application | et.exe |
| 企业定制版或特殊分支 | KWPS.Application(少见) | wps.exe |
我先说一下代码里怎么稳健地处理这个差异:用CLSIDFromProgID依次尝试Et.Application和KET.Application,哪个注册了用哪个。不要写死一个,否则换个版本就翻车。你可以在C++里做一个循环尝试,或者在安装包里做一次注册表探测,把结果写到配置文件里。
还有一个细节:WPS个人版本地安装时,如果你的机器上同时装了精简版、绿色版、应用商店版,注册表里的CLSID信息可能不完整。我遇到过一台机器上Et.Application能创建,但KET.Application找不到的情况。所以注册表探测这步别省。
2.2 检查“开发工具”和自动化权限,但别把它和VBA混为一谈
很多人有一个误区:以为C++调WPS必须要装VBA组件。其实不是。COM自动化是WPS对外暴露的接口,VBA是文档内部宏的执行引擎,两者没有必然绑定。C++调用WPS时,WPS只是作为一个“开着文件、供外部指令驱动”的进程存在,不需要文档里跑宏。
但有两个地方你要检查一下,否则调用时容易遇到莫名其妙的问题:
- 打开WPS表格,看“开发工具”选项卡是否存在。如果不存在,到“文件 -> 选项 -> 自定义功能区”里勾选“开发工具”。这一步不影响COM调用本身,但它能让你手动打开一个表格做联调时,能直接看到宏安全设置和自动化相关选项。
- “文件 -> 选项 -> 信任中心 -> 宏设置”里,不要选择“禁用所有宏而不通知”,否则打开带宏的xlsm文件时WPS可能直接跳过内容或者弹出安全警告,导致你的程序拿到的工作簿数据不完整。
另外,如果你的程序要读取的表格里包含公式,那么要注意一个现象:读公式单元格的值,返回的是“缓存值”,也就是说WPS加载文件之后并没有立即重算所有公式,读到的可能是上一次保存时的结果。想要强制重算,需要在打开Workbook后调用一次Calculate()方法,这一点我在后面代码里会提一嘴。
2.3 位数匹配:程序位数和WPS位数要尽量保持一致
如果你用的是64位Windows + 64位WPS,那么你的C++程序最好也编成64位。COM的跨位数调用并不是完全不行,32位程序确实能调64位进程暴露的COM对象(系统会通过代理进程做转接),但有时候会遇到注册表重定向、CLSID在WOW6432Node下找不到、IDispatch的VARIANT封送异常等问题。
我在生产环境里的经验是:别在这个地方炫技,程序位数和WPS位数保持一致,能少踩一半的坑。
3. 第一版代码骨架:从CoInitialize到取回第一个单元格
先说清楚,COM自动化这种路子,本质上就是“你通过IDispatch接口,按名字调用WPS暴露出来的方法和属性”。IDispatch没有编译期类型检查,全靠DISPID和VARIANT在运行时对齐,所以代码写起来比调用普通C++库啰嗦,但反过来也意味着你的程序只依赖固定接口,WPS升级后只要接口没变就能继续跑。
下面是我整理的读取单个单元格的最小可运行骨架,用纯Windows API + ATL的CComVariant帮助类(需要#include <atlbase.h>和#include <atlcom.h>):
#include <windows.h> #include <atlbase.h> #include <atlcom.h> #include <iostream> #include <string> // 获取IDispatch接口上的某个方法/属性DISPID DISPID GetDispId(IDispatch* pDisp, const wchar_t* name) { if (!pDisp) return -1; DISPID dispid = -1; OLECHAR* oleName = const_cast<OLECHAR*>(name); HRESULT hr = pDisp->GetIDsOfNames(IID_NULL, &oleName, 1, LOCALE_USER_DEFAULT, &dispid); return SUCCEEDED(hr) ? dispid : -1; } // 调用无参属性获取,得到VARIANT结果 HRESULT GetProperty(IDispatch* pDisp, const wchar_t* propName, VARIANT* pResult) { DISPID dispid = GetDispId(pDisp, propName); if (dispid == -1) return E_FAIL; DISPPARAMS dp = { nullptr, nullptr, 0, 0 }; UINT uiArgErr = 0; VariantInit(pResult); return pDisp->Invoke(dispid, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_PROPERTYGET, &dp, pResult, nullptr, &uiArgErr); } int main() { // 1. 初始化COM。主线程用ATL的CComPtr管理,线程模型建议APARTMENTTHREADED HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) return -1; // 2. 获取WPS表格的CLSID并创建实例 CLSID clsid; hr = CLSIDFromProgID(L"Et.Application", &clsid); if (FAILED(hr)) hr = CLSIDFromProgID(L"KET.Application", &clsid); if (FAILED(hr)) { std::cerr << "没有找到WPS表格的COM注册信息,请检查WPS安装状态" << std::endl; CoUninitialize(); return -1; } CComPtr<IDispatch> spWps; hr = CoCreateInstance(clsid, nullptr, CLSCTX_LOCAL_SERVER, IID_IDispatch, (void**)&spWps); if (FAILED(hr) || !spWps) { std::cerr << "创建WPS Application失败" << std::endl; CoUninitialize(); return -1; } // 3. 拿到Workbooks集合 CComVariant vBooks; hr = GetProperty(spWps, L"Workbooks", &vBooks); if (FAILED(hr) || vBooks.vt != VT_DISPATCH) { std::cerr << "获取Workbooks失败" << std::endl; spWps = nullptr; CoUninitialize(); return -1; } CComPtr<IDispatch> spBooks = vBooks.pdispVal; // 4. 调用Workbooks.Open方法打开文件 DISPID dispidOpen = GetDispId(spBooks, L"Open"); if (dispidOpen == -1) { std::cerr << "找不到Open方法" << std::endl; return -1; } CComVariant vPath(L"D:\\test.xlsx"); // 构造BSTR参数 VARIANTARG args[1] = { vPath }; // 注意:COM的DISPPARAMS里,参数顺序是反的,args[0]代表调用参数列表的最后一个/唯一一个 DISPPARAMS dpOpen = { args, nullptr, 1, 0 }; CComVariant vBook; UINT uiArgErr = 0; hr = spBooks->Invoke(dispidOpen, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_METHOD, &dpOpen, &vBook, nullptr, &uiArgErr); if (FAILED(hr) || vBook.vt != VT_DISPATCH) { std::cerr << "打开工作簿失败" << std::endl; return -1; } CComPtr<IDispatch> spBook = vBook.pdispVal; // 5. 获取Sheets集合 CComVariant vSheets; hr = GetProperty(spBook, L"Sheets", &vSheets); CComPtr<IDispatch> spSheets = vSheets.pdispVal; // 6. 取第一个Sheet DISPID dispidItem = GetDispId(spSheets, L"Item"); VARIANTARG sheetArgs[1]; sheetArgs[0].vt = VT_I4; sheetArgs[0].lVal = 1; // 第一个工作表 DISPPARAMS dpItem = { sheetArgs, nullptr, 1, 0 }; CComVariant vSheet; spSheets->Invoke(dispidItem, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_METHOD, &dpItem, &vSheet, nullptr, &uiArgErr); CComPtr<IDispatch> spSheet = vSheet.pdispVal; // 7. 获取Cells属性,参数为行和列 DISPID dispidCells = GetDispId(spSheet, L"Cells"); VARIANTARG cellArgs[2]; cellArgs[0].vt = VT_I4; cellArgs[0].lVal = 2; // 第2行 cellArgs[1].vt = VT_I4; cellArgs[1].lVal = 1; // 第1列 // DISPPARAMS参数顺序逆序,所以数组里cellArgs[0]对应调用中的第二个参数(列) // cellArgs[1]对应第一个参数(行) DISPPARAMS dpCells = { cellArgs, nullptr, 2, 0 }; CComVariant vCellRange; spSheet->Invoke(dispidCells, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_PROPERTYGET, &dpCells, &vCellRange, nullptr, &uiArgErr); CComPtr<IDispatch> spCell = (vCellRange.vt == VT_DISPATCH) ? vCellRange.pdispVal : nullptr; // 8. 读取Value属性 CComVariant vValue; hr = GetProperty(spCell, L"Value", &vValue); if (SUCCEEDED(hr)) { if (vValue.vt == VT_BSTR) { std::wcout << L"A2单元格内容: " << vValue.bstrVal << std::endl; } else if (vValue.vt == VT_R8) { std::cout << "A2单元格内容(数字): " << vValue.dblVal << std::endl; } else if (vValue.vt == VT_EMPTY) { std::cout << "A2单元格为空" << std::endl; } } // ...这里省略关文件、退出的代码,完整流程见下文 CoUninitialize(); return 0; }这段代码虽然能跑通读取单个单元格,但实际上离“可用的读取器”还很远。打开文件后你还需要处理工作簿关闭、进程退出、异常分支释放等问题。别急,先把COM对象树的释放顺序说清楚。
3.1 谁先谁后:COM对象树的释放顺序不能乱
WPS的自动化对象模型是一棵引用树:
Application(WPS进程) -> Workbooks -> Workbook -> Sheets -> Worksheet -> Range/Cells
你从父对象拿到子对象时,子接口上其实已经持有了父对象的引用(或者说系统帮你保持了一条引用链)。释放时必须反过来:先释放子对象,再释放父对象。如果先释放spWps,再释放spBook,在极端情况下会导致进程崩溃或产生僵尸进程。
实际工程中更省心的做法是:不手动逐级Release(),而是把它们全部作为函数内的局部CComPtr,让析构顺序和创建顺序自然反序。上面骨架里就是这么做的,在main()结束时,spCell先析构,然后spSheet、spSheets、spBook、spBooks、最后spWps,这个顺序是安全的。
另外还有一个常见的坑:关闭工作簿的时候,如果调Workbook.Close方法,它的参数比较多(保存变更、文件名等),很容易因为参数传错导致弹窗。为了省事,我在很多项目里根本不调用Close,而是直接调用Application.Quit(),让WPS进程整体退出。如果文件已经用只读方式打开,这个过程很干净。但如果程序中途崩溃了,WPS进程会残留,下次再启动COM自动化时可能连到旧进程,旧进程里还锁着文件。因此,每次打开文件时建议显式传ReadOnly:=True这个可选参数。
4. 数据量大时的正确打开方式:Range批量取值与VARIANT拆解
很多初学者按上面单格读的逻辑复制粘贴,做出一个循环后立刻崩溃:
// 反面教材:双循环逐格读取10000个单元格,卡到你怀疑人生 for (int r = 1; r <= 10000; r++) { for (int c = 1; c <= 20; c++) { // 每次循环都走一次跨进程COM调用,慢到令人发指 VARIANT v = ... 读Cells(r, c).Value ...; } }这不是C++性能差,也不是WPS慢,而是每个Cells().Value调用都是一次进程间通信。WPS的COM对象运行在独立的et.exe进程里,你的程序每读一个单元格,都要做一次RPC往返。我自己在一份1000行x 10列的表格上做过测试,逐格读取耗时约38秒,而一次性把整块Range读回来只花不到0.5秒,差了近百倍。
所以正确姿势是:直接用Worksheet.Range("A1:J1000")或者Range(Cells, Cells)拿到整块区域,再一次性读取这块Range的Value属性。此时返回的VARIANT是一个二维的SAFEARRAY,你要在内存里拆出来。
下面这段代码演示了怎么读取整块数据:
// 给定起始行、列,结束行、列,把整块区域数据读入vector bool ReadRangeToVectors(IDispatch* pSheet, int startRow, int startCol, int endRow, int endCol, std::vector<std::vector<std::wstring>>& outData) { // 1. 构造Range对象:调用pSheet的Range方法,参数用字符串"A1:J1000" DISPID dispidRange = GetDispId(pSheet, L"Range"); std::wstring rangeAddr; wchar_t buf[64]; // 这里做个简化,假设列名只到Z,实际生产环境要写一个列号转列名的工具函数 swprintf_s(buf, L"%c%d:%c%d", L'A' + startCol - 1, startRow, L'A' + endCol - 1, endRow); rangeAddr = buf; CComVariant vAddr(rangeAddr.c_str()); VARIANTARG rangeArgs[1] = { vAddr }; DISPPARAMS dpRange = { rangeArgs, nullptr, 1, 0 }; CComVariant vRangeObj; UINT uiArgErr = 0; HRESULT hr = pSheet->Invoke(dispidRange, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_METHOD, &dpRange, &vRangeObj, nullptr, &uiArgErr); if (FAILED(hr) || vRangeObj.vt != VT_DISPATCH) return false; CComPtr<IDispatch> spRange = vRangeObj.pdispVal; // 2. 读取整块Value CComVariant vValue; hr = GetProperty(spRange, L"Value", &vValue); if (FAILED(hr)) return false; // 3. 判断是否为二维数组 if (vValue.vt != (VT_ARRAY | VT_VARIANT)) { // 如果区域只有一个单元格,返回的就不是数组而是普通值 outData.resize(1); outData[0].resize(1); outData[0][0] = VariantToString(vValue); return true; } SAFEARRAY* psa = vValue.parray; long lBoundRow, uBoundRow, lBoundCol, uBoundCol; SafeArrayGetLBound(psa, 1, &lBoundRow); SafeArrayGetUBound(psa, 1, &uBoundRow); SafeArrayGetLBound(psa, 2, &lBoundCol); SafeArrayGetUBound(psa, 2, &uBoundCol); // 从SAFEARRAY中取数据,注意Excel/WPS返回的二维数组下标从1开始 // 而且整个数组是连续内存,按行优先存储 for (long r = lBoundRow; r <= uBoundRow; r++) { std::vector<std::wstring> rowData; for (long c = lBoundCol; c <= uBoundCol; c++) { CComVariant val; SafeArrayGetElement(psa, &r, &val); // 第2维的参数传的是row的下标地址,方法重载 rowData.push_back(VariantToString(val)); } outData.push_back(std::move(rowData)); } return true; }注意:上面代码里SafeArrayGetElement(psa, &r, &val)这个写法并不严谨,正确的二维取元素方式是构造一个long indices[2] = {r, c},传进去拿元素。上面的片段只是为了提醒下标概念。实际写的时候这样做:
long indices[2]; for (long r = lBoundRow; r <= uBoundRow; r++) { indices[0] = r; for (long c = lBoundCol; c <= uBoundCol; c++) { indices[1] = c; CComVariant val; SafeArrayGetElement(psa, indices, &val); ... } }这样读数据速度就正常了,而且无论表格多大,RAM能放下就瞬间读完。
4.1 单元格值类型判断:VT_BSTR、VT_R8、VT_DATE、VT_EMPTY
从SAFEARRAY里拆出来的每个VARIANT不一定是同一种类型。我见过最乱的表格,一行里前半段是数字(VT_R8),中间几列是字符串(VT_BSTR),还有一两列是空值(VT_EMPTY),日期则可能是VT_DATE也可能是VT_R8。
写一个统一的VariantToString函数是关键:
std::wstring VariantToString(const CComVariant& v) { switch (v.vt) { case VT_EMPTY: case VT_NULL: return L""; case VT_BSTR: return v.bstrVal ? v.bstrVal : L""; case VT_R8: return FormatDouble(v.dblVal); // 注意转字符串时别用默认精度 case VT_I4: return std::to_wstring(v.lVal); case VT_I2: return std::to_wstring(v.iVal); case VT_DATE: { SYSTEMTIME st = {0}; VariantTimeToSystemTime(v.date, &st); wchar_t buf[64]; swprintf_s(buf, L"%04d-%02d-%02d %02d:%02d:%02d", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); return buf; } case VT_BOOL: return v.boolVal == VARIANT_TRUE ? L"true" : L"false"; default: // 其他类型尝试强制转BSTR CComVariant copy(v); if (SUCCEEDED(copy.ChangeType(VT_BSTR))) return copy.bstrVal; return L""; } }日期是坑里的大头。WPS底层其实把日期存成浮点序列号,1842-10-01之后的日期都能表示。但有些表格里日期单元格直接存成了一个看起来像普通数字的值,比如45231.0,这时候只有和表头语义结合才能判断它是日期还是数字。所以我的经验是:不要试图让读取器自动识别日期,把原始VARIANT类型信息也保留一份,业务层需要时再判断。
4.2 想让公式运算结果正确,记得在打开文件后调Calculate
回到第2节埋的伏笔。如果你读的表格里单元格的值是公式计算出来的,而这些公式在文件中保存时还没有重算过,那么读到的很可能是旧缓存。处理办法是在Open成功后调用一次Calculate方法,但要注意:Calculate是整个Application级别的全工作簿重算,如果是超大表格可能耗时几秒到几十秒。
我一般只在文件打开后调用一次Workbook级别的Calculate(Workbook对象下也有Calculate方法,比Application级别覆盖面小一些)。如果你只是做数据预览,不关心公式最新结果,可以完全跳过这步。
5. 真机踩坑记录:版本、编码、释放顺序和无人值守
前面讲的都是正向流程,下面把这些年实际项目中踩过的坑集中说一下,全是“能复现、有解决方案”的硬经验。
5.1 版本差异:Et.Application还是KET.Application,以及精简版的“隐形坑”
有台内网机器,装的是定制版WPS,代码里用Et.Application创建成功了,但Open一个xlsx文件时总是返回E_FAIL。后来排查发现,那台机器的精简版WPS移除了对xlsx的加载支持,只能打开et格式。这个问题的通用排查方式:
- 用
reg query查看CLSID下有没有LocalServer32,确认是可执行文件还是DLL宿主; - 用系统自带的事件查看器看
et.exe是否崩了; - 最实用的一招:在程序里创建一个宏开关,先创建Application,再读
Version属性,把WPS版本号打出来。某些精简版能创建成功但Version都拿不到,这种直接报“WPS组件不完整”就行,别再往下走了。
5.2 中文乱码和BSTR转换
COM自动化里所有字符串都是UTF-16的BSTR,如果你的程序走的是控制台输出,直接std::wcout在中文Windows上通常没问题,但如果你要存文件、传JSON、送数据库,基本都需要转UTF-8。转换时别用WideCharToMultiByte默认参数,代码页要指定CP_UTF8:
std::string WStringToUtf8(const std::wstring& wstr) { if (wstr.empty()) return ""; int size = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), nullptr, 0, nullptr, nullptr); std::string result(size, 0); WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), (int)wstr.size(), &result[0], size, nullptr, nullptr); return result; }还有一个很隐蔽的坑:WPS读回来的BSTR里的某些特殊字符(比如全角空格、不间断空格\u00A0)和普通空格看起来一样,但你在代码里做字符串比较时永远不相等。处理表格数据时,建议统一做一次trim替换,把\u00A0替换成普通空格。
5.3 无人值守环境(服务、计划任务)下的调用姿势
如果程序以Windows服务的身份跑,或者计划任务设置在SYSTEM账户下,调用WPS自动化会非常不靠谱。原因很简单:WPS表格的自动化依赖用户的桌面会话,服务环境下没有交互式桌面,WPS的窗口/渲染初始化都异常。
这个问题我当时的解决方案是:把任务设计成“普通用户会话中运行的常驻进程”,不做成服务。也就是用户登录后,程序在后台静默运行,照样用COM自动化驱动WPS打开文件读取数据。如果业务确实必须在登录前就跑,那就别用WPS自动化,直接改走第三方C++库解析xlsx更稳。
另外,不管什么场景,程序结束时一定要确保WPS进程退出。release完所有COM对象后,调用spWps->Quit(),然后睡眠几百毫秒,再检查进程中是否还有et.exe,有的话可以FindWindow发关闭消息,或者留着由下一次GetActiveObject复用。我的习惯是:每次读完文件就Quit,不长期持有WPS进程,虽然启动WPS进程有开销(大约1~2秒),但长期持有带来的内存泄漏和文件锁问题更让人头疼。如果性能敏感,可以用进程池思路:预先把WPS进程拉起,维护一套空闲/占用状态。
5.4 Concurrent调用和多线程:COM的线程模型不是摆设
如果你打算在自己的程序里开多线程,每个线程各创建一个WPS实例去读不同文件,要注意COM初始化模型。WPS表格这个COM服务器,对线程模型的匹配比较敏感。我的建议是:
- 每个线程入口处调
CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); - 一个线程创建并只使用自己的WPS实例,不要跨线程传
IDispatch接口指针; - 线程结束时先Quit WPS,再
CoUninitialize()。
如果你非要在MTA模式下跨线程调用,不是不能用,但会遇到随机性的RPC_E_WRONG_THREAD错误,排查起来非常痛苦。工程上绕开这个坑最稳的方式就是“每线程一个实例”。
一点个人体会
做这类办公自动化的项目,最容易让人崩溃的不是代码怎么写,而是“别人机器上的环境”长什么样。我后来养成了一个习惯:写一个CheckWpsEnvironment()工具函数,在程序启动时先探测注册表里WPS各版本的CLSID是否存在、能否创建对象、能读取到的版本号是多少、能否正常打开一个内置测试文件。把这个工具放到日志里,绝大多数客户现场问题都能在半小时内定位。C++调WPS读取Excel这件事,技术难度其实不高,难的是把各种边界情况兜住。希望这篇文章能帮你少走几个弯路。
本文还有配套的精品资源,点击获取