NativeFlex 引擎日志:实现“零图片”的 UI 控件渲染
在开发 NativeFlex UI 引擎的过程中,我一直坚持一个执念:零图片 (Zero Images)。
与其堆砌几十 MB 的 PNG 资源文件,不如用纯粹的代码去绘制每一个像素。不是为了追求包体积,18MB 内存占用的极致性能。
今天,攻克了 Native UI 开发中最基础、但也最考验底层功底的三大难关:高性能圆角、像素级描边,以及原生窗口透明。
一、 为什么“画个圆角”这么难?
在 Web 前端写一个 border-radius: 8px 是理所当然的事,但在 C++ 和 Direct2D 的底层世界里,这一切都需要从零构建。
在初期遇到了三个典型问题:
- 锯齿与发虚:直接调用的绘图指令在低分屏上惨不忍睹,1px 的边框要么模糊成 2px 的灰线,要么出现断裂。
- 肉圆皮尖:背景色成功变成了圆角,但外层的边框依然是直角,导致视觉上出现极不协调的尖锐“毛刺”。
- 不支持透明:默认的 GDI 或 D2D 初始化往往导致窗口背景呈现不透明的黑色或白色,无法实现UI 标志性的“玻璃穿透感”。
二、 逻辑错误到完美
1. 打通原生透明通道 (Alpha Channel)
风格的核心在于“通透”。要让 UI 呈现出叠加、发光和半透明玻璃质感,首先必须解决渲染器的透明支持问题。
在 NFXRenderer 的初始化阶段做出了关键修正,放弃了默认设置,强制开启 Premultiplied Alpha 模式:
// 关键配置:DXGI_FORMAT_UNKNOWN 配合 PREMULTIPLIED 模式
// 这让渲染器能够正确处理 Alpha 通道,而不是将其渲染为黑色
props.pixelFormat = D2D1::PixelFormat(DXGI_FORMAT_UNKNOWN, D2D1_ALPHA_MODE_PREMULTIPLIED);
同时,重写了颜色解析器,使其支持标准的 8位 Hex 格式 (#AARRGGBB)。这使得我们可以在 XML 中直接定义如下样式,实现 50% 透明度的边框叠加效果:
<Box border="1 #8000F0FF" bg-color="#20000000" ... />
2. Snap 像素对齐算法
Direct2D 的描边(Stroke)默认是居中对齐的。绘制 1px 的线条时,它会跨越两个物理像素(各占 0.5),导致抗锯齿算法将其渲染为模糊的灰色线条。
为了解决这个问题,在渲染底层引入了 Snap 对齐与 0.5px 偏移补偿:
// 核心:将逻辑坐标强制吸附到物理像素网格
inline float Snap(float v) { return floorf(v + 0.5f); }
// 在绘制时进行偏移补偿,彻底消除 1px 锯齿
float offset = (static_cast<int>(strokeWidth) % 2 != 0) ? 0.3f : 0.0f;
D2D1_ROUNDED_RECT rr = D2D1::RoundedRect(
D2D1::RectF(
Snap(rect.left) + offset,
Snap(rect.top) + offset,
Snap(rect.right) - offset,
Snap(rect.bottom) - offset
),
radius, radius
);
3. 修复“肉圆皮尖”的全托管渲染
针对边框与背景形状不一致的问题,将渲染逻辑重构为“全托管模式”。不再在控件层直接调用 DX API,而是通过渲染器统一封装 FillRoundedRectangle 和 DrawRoundedRectangle。
NFXBox::OnPaint 实现如下,干净、高效:
void NFXBox::OnPaint(NFXRenderer* renderer) {
// 1. 获取 Yoga 布局计算出的几何尺寸
float w = YGNodeLayoutGetWidth(m_node);
float h = YGNodeLayoutGetHeight(m_node);
D2D1_RECT_F rect = D2D1::RectF(0, 0, w, h);
// 2. 绘制圆角背景 (支持透明度叠加)
if ((m_backgroundColor & 0xFF000000) != 0) {
if (m_borderRadius > 0.1f) {
renderer->FillRoundedRectangle(rect, m_borderRadius, m_backgroundColor);
} else {
ID2D1SolidColorBrush* bgBrush = renderer->GetCachedBrush(m_backgroundColor);
if (bgBrush) renderer->GetRT()->FillRectangle(rect, bgBrush);
}
}
// 3. 绘制矢量边框(彻底消除锯齿,支持 Alpha 混合)
if (m_borderWidth > 0.0f && (m_borderColor & 0xFF000000) != 0) {
if (m_borderRadius > 0.1f) {
renderer->DrawRoundedRectangle(rect, m_borderRadius, m_borderColor, m_borderWidth);
} else {
renderer->DrawRectangle(rect, m_borderColor, m_borderWidth);
}
}
}
3.1 输入法(IME)的坐标对齐
IME 是原生 UI 系统的“杀手”。当你在半透明窗口输入文字时,输入法选词框必须精准地跟随光标(Caret)。
- 难题:Yoga 引擎使用的是逻辑坐标系,而 Windows IME API 需要的是相对于窗口客户区的物理坐标。
- 恶心: 输入法的入口方式,3个输入法8个方式,那种恶心感是真的比吃了死苍蝇还难受。特别是那个Sogou!虽然我不用

三、 成果
经过这一系列的底层重构,NativeFlex 达成了指标:
- 内存占用:仅 18MB(包含完整的 UI 树、Yoga 布局引擎与渲染上下文)。
- 视觉效果:完美的抗锯齿圆角,锐利的 1px 边框,支持任意透明度的颜色叠加与玻璃质感。
- 开发效率:完全通过 XML 套 CSS 定义 UI,基本实现逻辑视觉的解耦。 可以把XML理解成HTML 属性就是CSS。
Before & After 对比:
- _修正前_:背景不透明,边缘模糊,圆角内陷,边框尖锐。
- _修正后_:通透的赛博蓝玻璃底板,丝滑的圆润边缘,半透明光边精准贴合,无任何视觉伪影。

四、 下一步计划
虽然已经实现了矩形体系的渲染,距离心中的 UI 还要更多元化的图形支持。接下来的路线图包括:
- 矢量圆支持:通过
NFXCircle实现真正的矢量圆绘制(雷达图、状态灯)。 - 动画系统:让边框颜色和透明度动起来,实现“呼吸”效果。(基本差不多了)
- 高级特效:DirectComposition 直接合成,引入亚克力(Acrylic)背景模糊。
NativeFlex 的每一步,都在向极致的 Native 性能致敬。

讲个笑话!
- 初心: 本来就想用duilib或者soui写个音速启动2026,老的确实看不下去支持也不好。
- 结果: 一个东西8个版本,文档更是五花八门。布局坐标全部硬来,套过去套过来。东西是写出来了。感觉总差点意思。都2026。想换种思维。写音速启动还遇见个奇葩BUG,拖入系统如回收站,系统类哈哈哈看图最后还是解决了。
- 导致: 为了吃个西瓜,去建了个农场。 值得还是不值得?我也不知道



- 自古以来就是酒养人,水养神,不到天亮不回魂,烟回名,酒回魂,烟酒到齐,夜销魂。’——选自散文《我在人家凑数的的日子》
/*
* 有脏东西在这里!
* 玄学BUG,下断点过 加MessageBox也能过
* 打断,线程,都没用,下面解决了
*/
virtual HRESULT STDMETHODCALLTYPE DragOver(DWORD grfKeyState, POINTL pt, DWORD* pdwEffect) override {
if (*pdwEffect == DROPEFFECT_NONE) {
*pdwEffect = DROPEFFECT_COPY;
}
if (!m_pFrame) return S_OK;
//脏东西修复->节流阀
static DWORD dwLastCheckTime = 0;
DWORD dwCurrentTime = ::GetTickCount();
if (dwCurrentTime - dwLastCheckTime < 15) {
return S_OK;
}
dwLastCheckTime = dwCurrentTime;
POINT point = { pt.x, pt.y };
::ScreenToClient(m_pFrame->GetHWND(), &point);
CPaintManagerUI* pManager = m_pFrame->GetPaintManager();
CControlUI* pControl = pManager->FindControl(point);
if (pControl) {
CTreeNodeUI* pNode = static_cast<CTreeNodeUI*>(pControl->GetInterface(_T("TreeNode")));
if (!pNode) {
CControlUI* pTemp = pControl->GetParent();
while (pTemp) {
if (pTemp->GetInterface(_T("TreeNode"))) {
pNode = static_cast<CTreeNodeUI*>(pTemp);
break;
}
pTemp = pTemp->GetParent();
}
}
if (pNode) {
CTreeNodeUI* pParentGroup = pNode;
while (pParentGroup->GetParentNode() != nullptr) {
pParentGroup = pParentGroup->GetParentNode();
}
int realJsonIdx = static_cast<int>(pParentGroup->GetTag());
if (m_nHoverGroupIdx != realJsonIdx) {
m_nHoverGroupIdx = realJsonIdx;
m_pFrame->SetCurrentGroupByIdx(realJsonIdx);
}
}
}
return S_OK;
}