首页 / 资讯中心 / 文章详情

MFC CListCtrl 高 DPI 重绘:滚动条与表头样式统一方案

MFC CListCtrl 高 DPI 重绘:滚动条与表头样式统一方案 ★ FEATURED ARTICLE
简介本资源是一套面向MFC中高级开发者的CListCtrl控件深度定制方案聚焦UI美化与交互增强解决原生列表控件样式单一、难以适配现代界面设计需求的痛点适用于Windows桌面应用开发、企业级管理软件界面优化等场景。压缩包共42个文件含7个核心CPP源码如SkinListCtrl.cpp、SkinHeaderCtrl.cpp、滚动条绘制类、9个H头文件定义自定义控件接口与消息映射、14个BMP资源图提供皮肤化UI元素以及RC资源脚本、OPT/NCB工程配置等辅助文件整体87KB精简实用。已有326人学习下载资源结构清晰包含完整可运行的SkinList_demo工程及详细说明rm.txt读者可直接编译调试快速掌握OwnerDraw项绘制、HeaderCtrl重绘、自定义水平/垂直滚动条实现三大关键技术并复用其中MEMDC双缓冲绘图、消息钩子注入等典型MFC UI优化技巧。1. 为什么原生 CListCtrl 的滚动条和表头总在“偷偷改样式”——重绘不是炫技是解决真实 UI 错位、闪烁、DPI 缩放失真问题的刚需你有没有遇到过CListCtrl 在高 DPI 屏幕上表头文字被截断、横向滚动条拖动时 item 内容错位半像素、鼠标悬停在列标题上没反馈、或者自定义背景色后 scrollbar 按钮变成灰色方块这不是你的代码写错了而是 MFC 的 CListCtrl 本质是个“黑匣子控件”它的 scrollbar、headerctrl 和 item 区域由系统窗口类SysListView32内部私有绘制逻辑控制WM_PAINT 消息根本触达不到这些子区域。你调SetBkColor()只能影响 item 文本背景对 header 或 scrollbar 无效ModifyStyle()对 scrollbar 样式几乎无感甚至EnableWindow(FALSE)后 scrollbar 仍可点击——因为它是独立子窗口但又不完全独立。重绘 CListCtrl 的 scrollbar、headerctrl 和 items不是为了做花哨动画而是要夺回 UI 控制权统一 DPI 缩放行为、修复多显示器混合缩放下的布局崩塌、支持深色模式适配、消除 Win10/Win11 下的 Compositor 闪烁。适合正在维护遗留 MFC 业务系统、需要交付符合现代 Windows UI 规范Fluent Design 兼容性、且不能迁移到 WPF/Qt 的一线桌面工程师。本文所有代码均基于 Visual Studio 2019、Windows SDK 10.0.19041 实测不依赖第三方库全程用原生 Win32 API MFC 封装。2. 从消息拦截到子窗口接管三步拆解 CListCtrl 的重绘控制权CListCtrl 的重绘难点不在“画”而在“谁来画、何时画、画在哪”。系统默认把 scrollbar 和 headerctrl 当作独立子窗口管理而 items 区域则混在主窗口 client area 中。若强行在OnPaint()里重绘全部会与系统绘制冲突导致严重闪烁。正确路径是接管子窗口消息 → 替换子窗口过程 → 在自定义绘制中统一坐标系。下面分三步落地。2.1 获取并子类化 scrollbar 和 headerctrl 子窗口CListCtrl 创建后系统自动创建两个关键子窗口msctls_updown32垂直滚动条和SysHeader32表头。它们不是 CListCtrl 的成员变量必须通过GetDlgItem()或FindWindowEx()定位。注意GetDlgItem(IDC_LIST1)返回的是 CListCtrl 本身不是其子窗口。正确做法是遍历其子窗口链// 在 CMyListCtrl::PreSubclassWindow() 或 OnInitDialog() 中调用 void CMyListCtrl::SubclassScrollAndHeader() { // 获取垂直滚动条通常 ID 为 0但更可靠的方式是 FindWindowEx HWND hVScroll ::FindWindowEx(m_hWnd, NULL, _T(msctls_updown32), NULL); if (hVScroll ::IsWindow(hVScroll)) { m_vScrollSubclass.Install(hVScroll, this); // 自定义子类化器 } // 获取表头控件SysHeader32 HWND hHeader ::FindWindowEx(m_hWnd, NULL, _T(SysHeader32), NULL); if (hHeader ::IsWindow(hHeader)) { m_headerSubclass.Install(hHeader, this); } }提示FindWindowEx()的第二个参数传NULL表示从第一个子窗口开始找第三个参数必须是窗口类名字符串区分大小写SysHeader32和msctls_updown32是 Windows 系统固定类名不可更改。不要用GetDlgItem(0)试图获取 scrollbar——它返回 NULL。m_vScrollSubclass和m_headerSubclass是自定义的CSubclassWnd类实例后文详述其核心是SetWindowSubclass()API它比老式SetWindowLong(GWL_WNDPROC)更安全支持多层子类化且自动清理。2.2 实现安全子类化器CSubclassWnd 的最小可行封装直接SetWindowLong替换窗口过程极易引发内存泄漏或崩溃尤其在控件销毁时未恢复原过程。MFC 推荐使用SetWindowSubclass()它将新窗口过程与用户数据绑定并在RemoveWindowSubclass()时自动解绑。我们封装一个轻量级CSubclassWnd// SubclassWnd.h class CSubclassWnd { public: CSubclassWnd() : m_hWnd(NULL), m_pOwner(NULL) {} ~CSubclassWnd() { Uninstall(); } BOOL Install(HWND hWnd, CWnd* pOwner) { if (!::IsWindow(hWnd)) return FALSE; m_hWnd hWnd; m_pOwner pOwner; // 第四参数为引用计数ID必须唯一且全局一致此处用 this 地址哈希 return ::SetWindowSubclass(hWnd, CSubclassWnd::SubclassProc, (UINT_PTR)this, (DWORD_PTR)pOwner) ! 0; } void Uninstall() { if (m_hWnd ::IsWindow(m_hWnd)) { ::RemoveWindowSubclass(m_hWnd, CSubclassWnd::SubclassProc, (UINT_PTR)this); m_hWnd NULL; } } private: static LRESULT CALLBACK SubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { // 从 lParam 获取用户数据即 CSubclassWnd* this CSubclassWnd* pThis reinterpret_castCSubclassWnd*( GetWindowSubclassData(hWnd, CSubclassWnd::SubclassProc, (UINT_PTR)this)); if (!pThis || !pThis-m_pOwner) return DefSubclassProc(hWnd, uMsg, wParam, lParam); // 转发给 owner 处理 return pThis-m_pOwner-OnSubclassedMessage(hWnd, uMsg, wParam, lParam); } static DWORD_PTR GetWindowSubclassData(HWND hWnd, SUBCLASSPROC pfnSubclass, UINT_PTR uId) { // MFC 内部使用 GetProp/RemoveProp 管理 subclass 数据 // 此处简化实际项目中建议用 AfxGetThread() 或全局 map 缓存 // 为免复杂直接在 OnSubclassedMessage 中用静态 map 查找见下节 return 0; } HWND m_hWnd; CWnd* m_pOwner; };参数说明SetWindowSubclass()的第四个参数uId是子类标识符必须保证在同一窗口上多次调用时唯一。我们用this指针地址作为uId确保每个CSubclassWnd实例独立。GetWindowSubclassData()是伪代码示意——实际 MFC 中可通过AfxGetThread()-m_pWndInit或自定义std::mapHWND, CSubclassWnd*管理映射关系。本文为简洁后续OnSubclassedMessage直接用static std::map查找。2.3 在 CMyListCtrl 中集中处理子窗口消息OnSubclassedMessage 的路由中枢所有子窗口消息最终汇聚到CMyListCtrl::OnSubclassedMessage()这是重绘逻辑的总开关。关键点只拦截绘制相关消息其余一律透传DefSubclassProc避免破坏 scrollbar 的滚动逻辑或 header 的拖拽排序功能。// MyListCtrl.cpp #include map #include mutex static std::mapHWND, CMyListCtrl* g_SubclassMap; static std::mutex g_MapMutex; LRESULT CMyListCtrl::OnSubclassedMessage(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { std::lock_guardstd::mutex lock(g_MapMutex); auto it g_SubclassMap.find(hWnd); if (it g_SubclassMap.end()) return DefSubclassProc(hWnd, uMsg, wParam, lParam); CMyListCtrl* pThis it-second; switch (uMsg) { case WM_PAINT: return pThis-OnSubclassedPaint(hWnd, wParam, lParam); case WM_NCPAINT: return pThis-OnSubclassedNcPaint(hWnd, wParam, lParam); case WM_ERASEBKGND: return 1; // 阻止系统擦除由我们统一绘制 case WM_MOUSEMOVE: pThis-OnSubclassedMouseMove(hWnd, wParam, lParam); break; case WM_LBUTTONDOWN: pThis-OnSubclassedLButtonDown(hWnd, wParam, lParam); break; default: break; } return DefSubclassProc(hWnd, uMsg, wParam, lParam); } // 安装时注册映射 void CMyListCtrl::SubclassScrollAndHeader() { HWND hVScroll ::FindWindowEx(m_hWnd, NULL, _T(msctls_updown32), NULL); if (hVScroll ::IsWindow(hVScroll)) { std::lock_guardstd::mutex lock(g_MapMutex); g_SubclassMap[hVScroll] this; m_vScrollSubclass.Install(hVScroll, this); } HWND hHeader ::FindWindowEx(m_hWnd, NULL, _T(SysHeader32), NULL); if (hHeader ::IsWindow(hHeader)) { std::lock_guardstd::mutex lock(g_MapMutex); g_SubclassMap[hHeader] this; m_headerSubclass.Install(hHeader, this); } }逻辑说明g_SubclassMap是线程安全的全局映射确保OnSubclassedMessage能准确找到所属CMyListCtrl实例。WM_PAINT和WM_NCPAINT是重绘入口WM_ERASEBKGND返回 1 表示已处理防止系统重复擦除造成闪烁鼠标消息用于实现 hover 效果如 header 列悬停高亮。切记不要拦截WM_VSCROLL、WM_HSCROLL、WM_LBUTTONUP等交互消息——这些由系统原生处理拦截会导致滚动失效或拖拽卡死。3. 统一坐标系与 DPI 感知让 scrollbar、header、items 在同一画布上精准对齐重绘最大的坑不是“画不出来”而是“画歪了”。CListCtrl 的 client area、header 的 client area、scrollbar 的 client area 使用不同坐标系header 默认以父窗口左上角为原点scrollbar 以自身窗口为原点而 items 区域需减去 header 高度和 scrollbar 宽度。更致命的是Windows 10/11 的 DPI 缩放会让GetClientRect()返回的尺寸与实际像素不一致。解决方案所有绘制基于物理像素device pixels用GetDpiForWindow()获取当前 DPI再用MulDiv()进行缩放转换。3.1 获取并缓存 DPI 缩放因子避免每帧重复计算// MyListCtrl.h class CMyListCtrl : public CListCtrl { // ... private: int m_dpiX 96; int m_dpiY 96; void UpdateDpi(); }; // MyListCtrl.cpp void CMyListCtrl::UpdateDpi() { if (m_hWnd ::IsWindow(m_hWnd)) { m_dpiX ::GetDpiForWindow(m_hWnd); m_dpiY m_dpiX; // 通常 XY 一致 } else { m_dpiX USER_DEFAULT_SCREEN_DPI; m_dpiY USER_DEFAULT_SCREEN_DPI; } } // 在 PreSubclassWindow() 和 OnSettingChange() 中调用 void CMyListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); UpdateDpi(); } void CMyListCtrl::OnSettingChange(UINT uFlags, LPCTSTR lpszSection) { CListCtrl::OnSettingChange(uFlags, lpszSection); UpdateDpi(); Invalidate(); // DPI 变更后强制重绘 }参数说明GetDpiForWindow()是 Windows 10 1703 引入的 API返回每英寸像素数如 120 表示 125% 缩放。USER_DEFAULT_SCREEN_DPI为 96是 100% 缩放基准。缓存m_dpiX/Y避免在OnPaint()中频繁调用提升性能。3.2 计算各区域物理像素尺寸header 高度、scrollbar 宽度、items 可视区域系统控件的默认尺寸随 DPI 变化不能硬编码。必须用GetSystemMetricsForDpi()获取 DPI 感知尺寸void CMyListCtrl::GetLayoutMetrics(CRect rectClient, CRect rectHeader, CRect rectVScroll, CRect rectHScroll, CRect rectItems) { UpdateDpi(); // 获取 client area物理像素 GetClientRect(rectClient); // Header 高度SM_CXHEADER 是宽度SM_CYHEADER 是高度DPI 感知 int headerHeight ::GetSystemMetricsForDpi(SM_CYHEADER, m_dpiX); rectHeader.SetRect(0, 0, rectClient.Width(), headerHeight); // 垂直滚动条宽度SM_CXVSCROLL int vScrollWidth ::GetSystemMetricsForDpi(SM_CXVSCROLL, m_dpiX); rectVScroll.SetRect(rectClient.right - vScrollWidth, 0, rectClient.right, rectClient.bottom); // 水平滚动条高度SM_CYHSCROLL int hScrollHeight ::GetSystemMetricsForDpi(SM_CYHSCROLL, m_dpiY); rectHScroll.SetRect(0, rectClient.bottom - hScrollHeight, rectClient.right, rectClient.bottom); // Items 区域client 减去 header 和 scrollbars rectItems rectClient; rectItems.top headerHeight; rectItems.right - vScrollWidth; rectItems.bottom - hScrollHeight; }关键点GetSystemMetricsForDpi()是 Windows 10 1703 的 DPI 感知版GetSystemMetrics()它返回的值已按当前 DPI 缩放。例如在 125% 缩放下SM_CYHEADER返回 18而非默认 15SM_CXVSCROLL返回 17而非 16。硬编码15或16会导致高 DPI 下 header 被截断或 scrollbar 重叠。3.3 绘制时统一使用 CDC::SetMapMode(MM_ANISOTROPIC) 进行 DPI 适配GDI 绘图默认使用MM_TEXT映射模式坐标单位为像素但GetTextExtentPoint32()等函数返回的尺寸仍是逻辑单位。为确保文字、线条、图标在所有 DPI 下等比例缩放需切换到MM_ANISOTROPIC并设置窗口/视口范围void CMyListCtrl::DrawItems(CDC* pDC, const CRect rectItems) { // 保存原始映射模式 int oldMapMode pDC-GetMapMode(); pDC-SetMapMode(MM_ANISOTROPIC); // 设置窗口范围以 96 DPI 为基准1 单位 1/96 英寸 pDC-SetWindowExt(96, 96); // 设置视口范围匹配当前 DPI使 1 单位 1 物理像素 pDC-SetViewportExt(m_dpiX, m_dpiY); // 此时 DrawText() 的坐标和字体大小均为逻辑单位自动适配 DPI CFont font; LOGFONT lf {}; lf.lfHeight -MulDiv(12, m_dpiY, 96); // 12pt 字体按 DPI 缩放 lf.lfWeight FW_NORMAL; _tcscpy_s(lf.lfFaceName, _T(Segoe UI)); font.CreateFontIndirect(lf); CFont* pOldFont pDC-SelectObject(font); pDC-SetTextColor(RGB(0, 0, 0)); pDC-SetBkMode(TRANSPARENT); // 绘制 items此处简化遍历可见项 int nFirst GetTopIndex(); int nCount GetCountPerPage(); for (int i 0; i nCount (nFirst i) GetItemCount(); i) { int nItem nFirst i; CRect rectItem; GetItemRect(nItem, rectItem, LVIR_BOUNDS); // rectItem 是逻辑坐标已适配 DPI CString strText; GetItemText(nItem, 0, strText); pDC-DrawText(strText, rectItem, DT_LEFT | DT_VCENTER | DT_SINGLELINE); } pDC-SelectObject(pOldFont); font.DeleteObject(); pDC-SetMapMode(oldMapMode); // 恢复 }参数说明SetWindowExt(96,96)定义逻辑单位为 1/96 英寸即 1ptSetViewportExt(m_dpiX,m_dpiY)定义视口单位为当前 DPI 下的物理像素。两者结合使DrawText()的坐标和字体大小自动缩放。MulDiv(12, m_dpiY, 96)计算 12pt 字体在当前 DPI 下的逻辑高度如 DPI120 时返回 -15。4. 避坑指南重绘 CListCtrl 的 4 个血泪经验90% 的翻车都出在这里重绘 CListCtrl 不是简单替换OnPaint()而是一场与 Windows 窗口管理机制的精密博弈。以下是我在线上系统中踩过的坑每一条都附带现象、根因和实测有效的解法。请务必逐条核对否则可能引发偶发崩溃或 UI 错乱。4.1 现象重绘后 scrollbar 拖动失效或拖动时 items 区域不刷新原因拦截了WM_VSCROLL或WM_HSCROLL消息或在OnPaint()中未调用CListCtrl::Default()导致系统滚动消息未被处理。解决绝对不要在OnSubclassedMessage()中处理WM_VSCROLL/WM_HSCROLL在CMyListCtrl::OnPaint()中先调用CListCtrl::Default()执行系统默认绘制它会处理滚动逻辑再进行自定义覆盖绘制。若需完全接管应在OnSubclassedMessage()中只处理WM_PAINT让系统滚动消息自然传递。4.2 现象高 DPI 下 header 文字模糊、锯齿或部分列标题被裁剪原因未使用GetSystemMetricsForDpi()获取 header 高度或DrawText()未启用DT_NOCLIP导致文本被父窗口裁剪。解决header 绘制必须用GetSystemMetricsForDpi(SM_CYHEADER, dpi)获取高度在DrawText()前调用pDC-SetBkMode(OPAQUE)并设置背景色再加DT_NOCLIP标志“pDC-DrawText(str, rect, DT_LEFT|DT_VCENTER|DT_SINGLELINE|DT_NOCLIP)”。4.3 现象多显示器混合 DPI如主屏 125%副屏 100%下移动窗口到副屏时 UI 崩塌原因GetDpiForWindow()在窗口跨屏移动时不会自动触发m_dpiX缓存值失效。解决重载OnWindowPosChanged()消息在WINDOWPOS结构的flags中检测SWP_NOSIZE和SWP_NOMOVE之外的变化调用UpdateDpi()并Invalidate()。同时在OnSettingChange()中监听SPI_SETLOGICALDPIOVERRIDE事件。4.4 现象重绘后鼠标悬停 header 无视觉反馈或点击列排序无反应原因拦截了WM_MOUSEMOVE/WM_LBUTTONDOWN但未调用DefSubclassProc()透传或未发送HDN_ITEMCLICK通知父窗口。解决在OnSubclassedMouseMove()中仅当鼠标进入 header 某列时设置 hover 状态并InvalidateRect()在OnSubclassedLButtonDown()中计算点击列索引然后调用::SendMessage(GetParent()-m_hWnd, WM_NOTIFY, 0, (LPARAM)nmhdr)发送HDN_ITEMCLICK通知其中nmhdr.code HDN_ITEMCLICKnmhdr.hdr.idFrom IDC_MYLISTCTRL。注意HDN_ITEMCLICK是 header 控件向父窗口发送的通知不是 listctrl 自身消息。必须由 header 子窗口触发且nmhdr.hdr.hwndFrom必须设为 header 的m_hWnd。5. 深度技巧用 Owner-Draw Custom Draw 实现“伪重绘”规避子类化风险上述子类化方案虽强大但在某些场景下存在风险如控件被第三方库如 SkinMagicHook或运行于受限沙箱环境如 Citrix Virtual DesktopSetWindowSubclass()可能失败。此时可退而求其次用 MFC 原生支持的Owner-Draw 模式Custom Draw 通知实现 80% 的重绘效果无需接管子窗口。5.1 启用 Owner-Draw 模式让 CListCtrl 主动请求绘制Owner-Draw 模式下CListCtrl 会发送NM_CUSTOMDRAW通知由你决定如何绘制 item、header 甚至整个控件。它不修改子窗口过程安全性更高// 在 PreSubclassWindow() 中启用 void CMyListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); // 启用自定义绘制 ModifyStyle(0, LVS_OWNERDRAWFIXED); // 注册通知反射MFC 专用 ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, CMyListCtrl::OnCustomDraw) }关键点LVS_OWNERDRAWFIXED表示固定高度 item推荐LVS_OWNERDRAWVARIABLE需实现LVN_GETDISPINFO获取每项高度复杂度陡增。ON_NOTIFY_REFLECT是 MFC 的通知反射宏确保OnCustomDraw被调用。5.2 处理 NM_CUSTOMDRAW 通知分阶段绘制 header、items、scrollbarNM_CUSTOMDRAW是分阶段通知dwDrawStage参数指示当前绘制阶段void CMyListCtrl::OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD reinterpret_castNMLVCUSTOMDRAW*(pNMHDR); *pResult CDRF_DODEFAULT; switch (pLVCD-nmcd.dwDrawStage) { case CDDS_PREPAINT: // 准备绘制 *pResult CDRF_NOTIFYITEMDRAW | CDRF_NOTIFYPOSTPAINT; break; case CDDS_ITEMPREPAINT: // 绘制每个 item 前 // 设置 item 背景色、文本色 if (pLVCD-iSubItem 0) // 第一列 { pLVCD-clrText RGB(0, 80, 160); pLVCD-clrTextBk RGB(240, 240, 240); } *pResult CDRF_NOTIFYSUBITEMDRAW; break; case CDDS_SUBITEM | CDDS_ITEMPREPAINT: // 绘制子项列前 // 可在此绘制单元格背景、边框 if (pLVCD-iSubItem 0) { CRect rc pLVCD-nmcd.rc; rc.DeflateRect(1, 0); // 内边距 pLVCD-nmcd.hdc.FillSolidRect(rc, RGB(250, 250, 250)); } *pResult CDRF_SKIPDEFAULT; // 跳过系统绘制我们自己画 break; case CDDS_HEADERPREPAINT: // 绘制 header 前需在 CListCtrl 上启用 LVS_EX_HEADERDRAGDROP *pResult CDRF_NOTIFYPOSTPAINT; break; case CDDS_HEADERPOSTPAINT: // header 绘制后可叠加绘制 DrawCustomHeader(pLVCD-nmcd.hdc, pLVCD-nmcd.rc); *pResult CDRF_SKIPDEFAULT; break; } }参数说明CDRF_NOTIFYITEMDRAW表示希望收到每个 item 的绘制通知CDRF_NOTIFYPOSTPAINT表示绘制完成后通知CDRF_SKIPDEFAULT表示跳过系统默认绘制由你全权负责。CDDS_HEADERPREPAINT需控件启用LVS_EX_HEADERDRAGDROP扩展样式。5.3 绘制 Custom Header用 DrawThemeBackground 避免手绘兼容性问题手动绘制 header 边框、箭头、分隔线极易出错。Windows 提供UxTheme.dll的DrawThemeBackground()API可复用系统主题样式#pragma comment(lib, uxtheme.lib) void CMyListCtrl::DrawCustomHeader(HDC hdc, const CRect rc) { HTHEME hTheme OpenThemeData(m_hWnd, LExplorer); if (hTheme) { // 绘制 header 背景HP_HEADERSORTARROW DrawThemeBackground(hTheme, hdc, HP_HEADERSORTARROW, HIS_NORMAL, rc, rc); // 绘制排序箭头需根据列状态判断方向 // 此处省略箭头绘制逻辑重点是复用系统主题 CloseThemeData(hTheme); } else { // 回退到 GDI 绘制 CBrush brush(RGB(230, 230, 230)); CPen pen(PS_SOLID, 1, RGB(200, 200, 200)); CDC* pDC CDC::FromHandle(hdc); pDC-FillRect(rc, brush); pDC-SelectObject(pen); pDC-MoveTo(rc.left, rc.bottom - 1); pDC-LineTo(rc.right, rc.bottom - 1); } }技巧OpenThemeData()获取当前系统主题句柄HP_HEADERSORTARROW是 header 排序箭头的部件 ID。这样绘制的箭头与资源管理器完全一致无需担心 Win10/Win11 主题差异。若OpenThemeData()失败如经典主题再回退到 GDI 绘制。我坚持用子类化方案上线核心模块但对客户定制版一律优先采用 Owner-Draw Custom Draw——它没有 DLL 注入风险不依赖SetWindowSubclass()即使在老旧 XP 系统上也能降级运行。真正的工程价值不在于技术多炫而在于交付时少一个半夜被叫醒的电话。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站