自绘界面库高DPI适配实战:从DPI虚拟化到PerMonitorV2
发布时间:2026/9/7 16:23:53 作者:尧图编辑部 阅读量:1,286

1. 为什么自绘界面库比普通MFC/WPF更容易在高DPI下翻车1.1 先看清DPI虚拟化这张“遮羞布”很多做Windows桌面应用的兄弟第一次接触高DPI问题时都会经历一个“错觉阶段”明明Win32程序在4K屏上没做什么处理界面也“能用”只是看着有点糊而已。这种“能用”的假象其实是系统在背后做了一件非常粗暴的事情——DPI虚拟化。所谓DPI虚拟化就是当你的程序没有声明自己具备DPI感知能力时Windows会自动认为你是在96 DPI100%缩放下绘制的然后把你渲染出来的整张画面当成一张位图强行拉伸到当前显示器的实际缩放比例上。比如你在150%缩放的屏幕上跑程序系统就把你的界面放大1.5倍再呈现出来。文字和线条经过这种非等比位图插值边缘必然发虚这就是“糊”的来源。对普通窗口程序来说这个机制还能勉强兜底。可对TinUI这种全自绘的轻量级界面库来说DPI虚拟化带来的伤害是成倍的。为什么因为TinUI没有依赖系统控件窗口里每一像素都是代码用GDI、GDI画上去的——背景是FillRect画出来的按钮是画边框加文字画出来的列表项是逐行绘制出来的。系统虚拟化只能整体拉伸最终的绘制结果它无法理解你内部的布局逻辑、无法重新排版、无法让文字更清晰。于是你会看到三个典型症状文字边缘重影、控件间距被拉伸得不成比例、图标和位图出现明显的锯齿。1.2 TinUI全自绘架构带来的三处高压区我最初在TinUI里跑高DPI适配测试时总结出了三个系统控件方案根本不会遇到的“高压区”这里单独拉出来讲是因为后面所有的改造方案都围绕它们展开。第一是字体渲染。系统Button在DPI变化时会自动收到WM_CTLCOLORBTN之类的通知字体跟着换TinUI里所有文字都是调用DrawText / TextOut绘制字体是代码里CreateFont创建的。如果创建时写死了一个磅值DPI变了字体根本不会自己变还按原来的像素尺寸画界面就挤压或者稀疏。第二是命中测试与鼠标坐标。TinUI的点击事件是通过处理WM_LBUTTONDOWN、WM_MOUSEMOVE拿到的坐标来判断命中了哪个控件的。这些坐标是物理像素坐标也就是窗口客户区的真实像素。当界面被ScaleFactor放大之后如果命中测试不把这个物理坐标换算回逻辑坐标你会发现点按钮经常点偏或者干脆点不到。第三是缓存位图。为了性能TinUI在处理复杂界面时往往会先把某个控件或某个分层面板绘制到内存位图MemoryDC里画面变化时再整体BitBlt上去。问题在于内存位图一旦创建它的像素尺寸就固定了。在96 DPI下创建的位图到了150% DPI的屏幕上直接拉伸显示整个面板都是糊的。这一条最坑因为很多时候你检查绘制代码逻辑完全正确但显示效果就是不行其实是缓存位图的锅。2. DPI感知模式的选择别让进程替你背锅2.1 三种感知级别选错代价有多大Windows提供了三条高DIP感知路径TinUI在做适配前第一步不是改绘制代码而是先明确进程的DPI感知级别。这一步错了后面所有的ScaleFactor计算都建立在沙子之上。第一种是DPI_AWARENESS_UNAWARE也就是完全不感知。进程把所有UI都按96 DPI布局系统负责拉伸。代价就是永远模糊而且随着屏幕分辨率越来越高这种虚拟化的拉伸模糊会越来越明显。TinUI作为追求轻量、清晰的库这一档直接就排除了。第二种是DPI_AWARENESS_SYSTEM_AWARE系统感知。进程启动时读取主显示器的DPI并按这个DPI进行布局。如果你只在主屏显示、不跨屏拖动这一档基本够用。但只要把窗口拖到一个缩放比例不同的副屏上Windows会对你的程序窗口重新执行“伪虚拟化”——即不考虑你实际是按自绘渲染的直接做位图缩放糊的毛病又回来了。多显示器已经是开发者的标配环境这一档也只能作为过渡方案。第三种是DPI_AWARENESS_PER_MONITOR_AWARE按监视器感知。系统完全不干预你的界面缩放所有DPI变化都通过WM_DPICHANGED消息通知窗口程序由你自行处理。这就是TinUI高DIP适配真正要落地的感知级别——画面是否清晰、是否变形全部由自己的绘制代码负责。如果再叠加一个V2Per-Monitor V2的概念系统还会额外支持子窗口DPI通知和WM_DPICHANGED时窗口大小的自动调整建议对TinUI这种单窗口自绘架构来说V2能减少不少手工活。这三种级别的代价差异我用一个表格直观对比感知级别主屏显示跨屏拖动文字清晰度需要做的事UNAWARE模糊模糊差什么都不用做但体验极差SYSTEM_AWARE正常跨屏后模糊主屏尚可按系统DPI布局一次PER_MONITOR_V2正常实时重绘好完整适配动态响应消息2.2 在TinUI工程里正确设置Per-Monitor V2在项目里声明DPI感知有两种主流方式Manifest声明和API动态设置。Manifest方式的好处是进程启动时系统就能识别渲染行为从一开始就是正确的API方式则适合某些特殊场景下在代码里临时切换。Manifest里需要加入的配置如下这是最推荐的方式TinUI的Demo工程我建议把这段直接写进项目已有的.manifest文件assembly xmlnsurn:schemas-microsoft-com:asm.v1 manifestVersion1.0 application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness dpiAware xmlnshttp://schemas.microsoft.com/SMI/2005/WindowsSettingstrue/dpiAware /windowsSettings /application /assembly注意dpiAwareness和dpiAware两个节点建议一起写前者是Win10 1703之后的机制后者是Win10早期版本和Win8.1的兼容方案。两个节点同时存在时系统会优先采用PerMonitorV2在更老的系统上则会退化为dpiAware里声明的Per-Monitor模式。如果你的TinUI模块是作为DLL被宿主程序加载的而宿主程序没有声明DPI感知那就必须在加载后、创建任何窗口前调用SetProcessDpiAwarenessContext。代码大致是这样if (!SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)) { // 老系统上使用备选方案 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE); }这里有一个很多人忽略的细节SetProcessDpiAwarenessContext如果调用成功会立刻改变进程的感知上下文但此时已经创建的系统级GDI对象如系统字体、程序图标不会自动刷新。如果在创建窗口前调用通过窗口过程拿到的Msg式新增和坐标转换是正确的如果在创建窗口后调用那已经注册窗口类的程序会有很大概率出现客户区尺寸与期望不符的情况。所以务必要在所有窗口创建之前甚至在WinMain入口的第一步就完成这一步。2.3 动态切换感知级别时的隐藏约束进程的DPI感知级别一旦设置就不能在同一进程生命周期内从PerMonitorV2改回别的模式只能从较低的感知度切换到较高的感知度方向不可逆。MSDN的文档笼统一句“process DPI awareness is per process”但没说完的是如果你在代码里尝试把一个已经处于PerMonitorV2的进程改为SystemAware调用会直接失败。这对TinUI这类库意味着什么意味着你不能在设计器预览和运行时之间动态切换感知级别必须让宿主进程在初始化阶段一锤定音。如果你想做“实时预览不同DPI效果”的调试工具只能为每个DPI启动一个独立进程或者使用虚拟显示器机制来触发WM_DPICHANGED。还有一条隐藏约束与GDI对象池有关。在系统Aware模式下GetDeviceCaps(hdc, LOGPIXELSX)返回的是当时启动屏的DPI而这个值在进程生命周期内不会变化。切到PerMonitorV2后这个API才会在WM_DPICHANGED之后返回新的DPI。所以TinUI内部凡是涉及DPI查询的地方都不要缓存一次性结果统一通过一个可更新的全局上下文对象去获取否则跨屏拖动时拿到的还是旧DPI界面缩放自然就错乱了。3. 用ScaleFactor把整个坐标系“抬”起来3.1 为什么不能直接在绘制代码里乘系数有些人在做高DPI适配时会走一条捷径拿到当前DPI后在每段绘制代码里给尺寸乘以一个系数。比如画一个按钮矩形原本是Rectangle(10, 10, 100, 30)现在就写成Rectangle(10 * scale, 10 * scale, 100 * scale, 30 * scale)。我最初也这样改过结果一个列表控件改完后所有子控件的相对位置全部错乱。问题出在乘系数这个动作放错了层级宽度乘以系数、间距乘以系数、字体高度乘以系数、滚动条尺寸乘以系数但它们的坐标基准并不统一。有的控件坐标是相对父容器的有的是相对整个窗口的还有的是相对某个Anchor的锚点。手动在每处乘很快就会算乱而且代码可读性极差后期维护基本等于重写。正确做法是把DPI缩放从“绘制细节”提升到“架构层面”。在TinUI里我引入了一个全局唯一的DpiContext结构所有控件在布局和绘制时都从这个上下文里取缩放比例struct DpiContext { float scaleX; float scaleY; float pixelsPerDip; UINT dpiX; UINT dpiY; };dpiX和dpiY通过GetDeviceCaps(hdc, LOGPIXELSX)获取scale dpi / 96.0f。控件布局时不再直接使用物理像素常量而是从一个DpiConvert(int logicalPixel)函数拿转换后的值。这样做的核心收益在于逻辑尺寸在代码里始终是固定的比如按钮宽度恒为80逻辑像素系统缩放比例变化时只需要更新全局DpiContext所有控件就随之整体缩放不用满文件去找乘系数的地方。3.2 逻辑坐标与物理坐标的双轨设计TinUI在适配高DPI时我建议将整个坐标体系明确分成两条轨道逻辑坐标Layout Coordinate和物理坐标Rendering Coordinate。逻辑坐标是开发者在设计界面时脑子里想象的坐标系统基准始终是96 DPI所有控件的位置、尺寸、间距都用逻辑值描述。物理坐标是真正传给GDI / GDI进行绘制的坐标等于逻辑坐标乘以DPI缩放比例。绘制阶段必须双重换算GDI的绘制函数需要物理坐标但控件的布局计算、父子的包含关系判断、对齐计算却应该使用逻辑坐标。否则会出现一个很隐蔽的Bug某个控件在布局时的宽度是100由于父容器占位计算使用了缩放后的物理值子控件的坐标计算却使用了未缩放的值两套体系混在一起只有某一个特定DPI下才能碰巧对上。我把这个双轨换算统一收到两个接口里TinUI所有控件仅依赖这两个接口禁止直接在控件内部调用GetDeviceCapsLogicalToPhysical(int logical)返回物理像素用于GDI绘制PhysicalToLogical(int physical)返回逻辑像素用于鼠标点击、命中测试、碰撞检测。命中测试特别要提醒一句Windows的鼠标消息给出的坐标是客户区物理像素。你画控件用的是物理坐标但命中判断如果用物理坐标去对比逻辑布局区域跨越1.5倍以上DPI时偏差会非常明显。正确姿势是先把鼠标坐标从物理降到逻辑然后在逻辑坐标系里做全部的范围判断。3.3 控件布局重排的关键顺序布局重排的顺序问题是我在一次列表项选中高亮错位事故里花了整整一个下午才定位到的。这里说的顺序不是代码里函数执行的先后而是“控件尺寸变更”与“子控件位置变更”的级联方向。在窗口收到DPI变化后TinUI会触发所有控件的RecalcLayout。此时正确顺序应该是先更新全局DpiContext再按层级从根容器开始逐级向下重算每个控件自己的尺寸然后再依次重算子控件的坐标。如果你反着做——先重算子控件或者某个子面板独立先刷新——就会导致父控件的物理宽度变了但子控件的锚定位置还没重新对齐画面呈现出一部分放大了、一部分还停在原位的撕裂感。另外一个需要留心的细节是滚动条和滑块这类带交互边界的控件。重算物理尺寸后必须同步重算它们的滚动范围、滑块长度以及滚动手势对应的逻辑偏移量。TinUI的滚动条属于自绘控件如果只重画了滑块位置而没有重算滚动比例用鼠标滚轮滚动时会出现内容跳页或怎么也滚不到底的怪现象。4. WM_DPICHANGED窗口跨屏时的第二次启动4.1 消息时序里最容易搞错的三个节点PerMonitorV2感知下的窗口在跨屏移动时系统会向窗口过程发送WM_DPICHANGED通知新DPI和系统建议的新窗口矩形。这个流程看似简单但消息与消息之间的先后关系直接决定了重绘是否闪烁、尺寸是否突变。最容易出错的是这三个节点第一WM_DPICHANGED发送时窗口的尺寸此时还是旧值系统只是通过lParam给出了一个建议矩形SuggestedRect并不是说窗口已经变了。你要做的第一件事是读取wParam里的新DPI更新DpiContext第二步才谈得上是否采纳lParam的矩形。第二在PerMonitorV2模式下系统会先发送WM_DPICHANGED紧接着发送WM_WINDOWPOSCHANGING最后才是WM_WINDOWPOSCHANGED。如果用SetWindowPos主动改变窗口尺寸会打断这条消息链的传递节奏导致部分子界面收不到后续的绘制指令。比较稳妥的做法是在WM_DPICHANGED里只更新DPI上下文和标记一个刷新请求真正的尺寸修改交给系统根据lParam自动处理不在这个消息里主动SetWindowPos。第三WM_DPICHANGED之后不一定马上就是WM_PAINT。在自绘架构里窗口的客户区不会因为DPI变化自动标记为无效必须手动InvalidateRect(hwnd, NULL, TRUE)。这一步遗漏了你会看到缩放比例变了但窗口画面还是旧的大小只有鼠标滑过那些区域才一点一点刷新出来特别诡异。4.2 建议矩形使用与窗口尺寸震荡问题WM_DPICHANGED的lParam里带着系统计算好的建议矩形很多教程会让你直接SetWindowPos过去。在PerMonitorV1时代这样做没问题但V2时代系统自己就会按建议矩形调整窗口大小你再去手动设置反而可能触发“窗口尺寸震荡”。什么叫尺寸震荡就是窗口在跨越两个不同DPI的显示器边界时由于我们自己设置尺寸和系统的自动调整互相叠加窗口不断在两种尺寸之间来回跳动边界处闪烁严重。原因就是系统先调整了一次我们紧接着又调整了一次而第二次调整又把DPI的变化重新触发了一遍。建议矩形本质上是一个参考值不是必须执行的指令。TinUI的根窗口在处理WM_DPICHANGED时我会先判断屏幕工作区是否能容纳这个建议矩形。如果窗口当前是最大化状态或者我自己已经控制了窗口尺寸比如固定尺寸的弹窗就只更新DPI上下文不采纳建议矩形取而代之的是按照新ScaleFactor重新计算期望窗口大小。这样既平滑过渡又不会在边界上来回拉扯。4.3 刷新策略全量重建还是增量更新DPI变化后整个窗口的绘制参数都变了这里推荐的做法是“全量标记失效、增量重建资源”。两个动作要同时做并且有先后先是把所有可见控件的绘制区域标记为无效等WM_PAINT到来时再按需重建必要的GDI资源。为什么不推荐全部立即重建因为GDI对象的创建开销虽然比COM小但在高频率跨屏拖动时窗口每经过一块不同DPI的屏幕就会触发一次WM_DPICHANGED如果每次都把所有字体、画刷、位图全部销毁重建频谱上很容易出现周期性卡顿。更合理的办法是为字体和画刷建立一个带DPI维度的缓存池缓存键是“DPI值 资源描述符”比如std::mapstd::pairUINT, std::wstring, HFONT g_fontCache;DPI变化时先查缓存命中就直接复用未命中才创建新的GDI对象。这样在跨屏拖动的过程中同一块屏幕上反复经过时不会反复创建字体性能表现要平滑得多位图的更新则需要区分如果位图里承载的是静态内容如图标、固定背景DPI变化后必须要重新按新DPI创建并重绘一遍如果位图承载的是动态内容比如每一帧都在变的动画帧则位图应该始终使用最高DPI创建再在低DPI上做缩绘否则会重复重建导致成本过高。5. 字体与GDI对象的DPI适配细节5.1 GDI字体不会自己“变清晰”进了第5节我们聊一个看上去简单、实际上拦住了很多人的问题字体。DPI变化后如果只是把窗口区域重新用FillRect填充了一遍文字照样会模糊或错位。因为GDI字体创建后它的像素大小是固定的不会感知DPI变化。CreateFont的最后一个高度参数在不同映射方式下含义不同。在默认的MM_TEXT映射下这个值就是像素高度。假设我在96 DPI下创建了一个高度为18像素的字体到144 DPI屏幕上它依然按18像素高度绘制——物理尺寸没有变但在新的DPI下18像素看起来就明显小了一圈与周围按新DPI放大后的控件格格不入。要真正“变清晰”核心不是提高字体大小而是让字体尺寸始终和当前DPI成正比。也就是说字体的像素高度要跟ScaleFactor一起变动。我建议把创建字体的逻辑统一收敛到一个内部方法里根据DPI重新计算高度HFONT CreateScaledFont(HDC hdc, const std::wstring faceName, int pointSize) { int dpi GetDeviceCaps(hdc, LOGPIXELSX); int pixelHeight MulDiv(pointSize, dpi, 72); return CreateFontW(-pixelHeight, 0, 0, 0, FW_NORMAL, ...); }注意这里用MulDiv(pointSize, dpi, 72)不是pointSize * dpi / 96。前者是“磅值到像素”的标准换算72磅恰好等于1英寸96 DPI下18磅正好是24像素后者是按缩放比例放大像素值两者在某些DPI下会差几个像素文字行高和控件高度对不上时排查半天都找不到根源。5.2 磅值与像素的换算公式以及缓存设计再展开说一下换算公式因为经常有人在这上面翻车。字体的“磅值”是物理单位1磅 1/72英寸。像素是设备相关单位和屏幕DPI绑定。二者换算关系像素高度 磅值 × DPI / 72这个公式写进代码时必须使用整数运算的MulDiv来避免浮点误差int pixelHeight MulDiv(pointSize, dpi, 72);而绝大多数UI库内部处理缩放时会在逻辑像素上乘ScaleFactor即像素高度 逻辑像素 × DPI / 96两条公式混用就会产生混乱。TinUI里我的处理方式是“统一走逻辑像素”路线所有字体设计时只定义一个逻辑像素字号运行时乘以当前DPI的ScaleFactor。字体的磅值只用于美术设计稿的交接场景运行时代码里不再直接使用磅值。字体样式的另一个隐藏问题有些字体在某DPI下渲染会出现基线偏移。最典型的是中文字体与英文字体混排时GDI的TextOut针对不同的字符集有不同的字体回退机制DPI变化后回退到的默认字体可能变化。为了避免这种不可控建议在创建字体时通过SetMapperFlags或直接指定DEFAULT_CHARSET配合中文字体名减少回退不确定性。缓存设计方面除了前面提到的hfong缓存池建议还把每个字体的“实际像素高度”也一并缓存下来。因为布局代码经常要拿到TextHeight来计算控件行高如果每次重新MeasureFont跨屏拖动时的重排性能会很难看。我的缓存表结构大概是缓存字段说明DPI创建时的DPI值FontDesc字体名称字号样式拼接的键HFONTGDI字体句柄PixelHeight已经换算好的像素高度这样做以后同一个DPI下反复创建同一种字体只会产生一个GDI句柄布局进程也能直接复用高度值。5.3 位图与画刷对象的重建时机位图和画刷需要重建前面已经提过。这里再精确一下“时机”。画刷HBRUSH相对简单它只跟颜色有关与DPI无关。但如果你创建的画刷关联了位图模式比如用CreatePatternBrush创建的花纹背景那么DPI变化时位图的花纹密度和尺寸就不匹配新坐标了必须销毁重建。在位图方面TinUI里最多的场景是预渲染的圆角按钮或阴影面板。这类位图承载的是已经自行绘制好的高成本内容。DPI变化后如果只是拉伸显示边缘会明显发虚。我的做法是在WM_DPICHANGED处理完DpiContext更新后发出一个消息通知所有持有RGB位图的对象执行RecreateBitmap。每个对象在自己的HandleDpiChanged回调里根据缓存判断是否需要重建。这里有一个性能优化的小技巧值得展开说。窗口跨屏移动时WM_DPICHANGED的触发频率很高。如果在一次移动中反复跨越两块显示器边界位图也会被反复重建。处理方式是在RecreateBitmap里加一个“惰性判断”只有在真正进入WM_PAINT、位图即将被BitBlt之前才去检查位图尺寸是否与当前ScaleFactor匹配不匹配才重建。这样就算DPI消息连续来几次只要最终画面只需要一个尺寸的位图就只重建一次中间抖动全部被吸收掉了。6. 我在TinUI高DPI适配中踩过的坑与排查思路6.1 现象一界面糊了但又不是完全糊这个现象让我一开始非常困惑。把TinUI的Demo窗口放到150%缩放屏幕上标题栏是清晰的系统菜单是清晰的但窗口客户区里自己画出来的部分全都是糊的。当时我直接怀疑是绘制代码调用了没开启抗锯齿的GDI函数折腾半天设置各种画笔质量都没有效果。后来排查到根因才明白这不是绘制质量的问题而是进程根本没被声明为DPI感知窗口客户区整体被系统进行了一次虚拟化拉伸。标题栏和系统菜单属于系统绘制区系统自己绘制时就是按实际DPI来的所以清晰客户区是程序自己画完再由系统拉伸所以糊。排查这个现象有一个非常快的方法用Spy看窗口拿到WM_DPICHANGED后进程的DPI感知上下文是否生效或者在窗口Proc里临时附加一个OutputDebugString观察跨屏拖动时是否打印了DPI变化。如果完全没有输出十有八九就是进程未声明PerMonitorV2。这时的修复方向不是改绘制代码而是回到第2节补Manifest或调API。6.2 现象二字体清晰但控件间距不对这个现象出现在感知级别正确、字体也做了缩放之后。文字本身很锐利但一个按钮里文字上下留白明显不对称或者列表项的行距突然变得特别拥挤。问题出在布局代码里混用了“逻辑坐标”和“物理坐标”。按钮高度在逻辑坐标下是32但在计算文字位置时我直接用了某个在96 DPI下缓存的TextMetrics值。DPI变成144后字体物理高度由18变成27但按钮内部的文字垂直居中计算还是拿旧值套用于是文字看起来就偏下了。修复思路就是坚持第3.2节的双轨原则所有涉及文字尺寸的参与运算都必须经过LogicalToPhysical转换不能只对按钮矩形做缩放而忽略字体度量值。你可以在Debug版本里加一句断言帮助定位——文字绘制时的坐标基线与控件矩形的中线偏移超过2个物理像素就触发断点输出这样就能逐步把所有漏网之小鱼清理干净。6.3 现象三窗口拖到另一块屏幕后瞬间变大变小多显示器环境下把TinUI窗口从100%缩放的屏幕拖到150%缩放的屏幕窗口尺寸会先瞬间变大一点点又缩回来视觉上有一个非常明显的“呼吸”感。这背后的原因是消息时序问题。跨屏时系统先发送WM_DPICHANGED同时在PerMonitorV2下自动调整窗口尺寸。如果我们在WM_DPICHANGED里手动SetWindowPos自己设置的矩形和系统正在进行的调整产生竞争就会在短时间内发生两次尺寸变更。解决方式就是把WM_DPICHANGED里主动调整尺寸的代码去掉只做三件事更新DpiContext、刷新控件布局、InvalidateRect。窗口尺寸由系统按建议矩形自动调整我们只需要在WM_SIZE里感知新尺寸并触发重排即可。调完之后“呼吸”感立刻消失跨屏过渡非常顺滑。如果遇到窗口最大化时跨屏的情况建议矩形实际上是不可用的因为最大化窗口的期望尺寸由系统根据工作区决定并不等于建议矩形。这种情况正确做法是保持最大化状态只更新DPI上下文让WM_SIZE按新工作区重新计算客户区重排控件即可。强行套建议矩形会把一个最大化窗口搞成普通窗口大小操作非常难受。折腾完这一整套我自己最大的体会是高DPI适配不是一个“加几行代码”的功能而是一整套从进程声明、坐标体系、资源生命周期到GDI对象缓存的系统性改造。TinUI这类自绘库的优势在于只要你把ScaleFactor和逻辑坐标系的架构搭建好适配是完全可以做到平滑且高性能的。反而那些依赖系统控件的框架由于系统内部的DPI处理往往不可控到了特定缩放比例下还是会冒出莫名其妙的问题。所以当初选择自绘这条路从长期看反而躲掉了很多StretchBlt式的兼容性陷阱。项目源码里那段DpiContext和字体缓存实现后来也被我直接搬到了另外几个Win32工具项目里几乎是无痛复用。如果你也在折腾TinUI或者类似的自绘框架建议优先把这一层地基打牢后面的控件绘制都会轻松很多。