首页 » 技术文章

NativeFlex UI 引擎日志

NativeFlex 引擎日志:实现“零图片”的 UI 控件渲染

在开发 NativeFlex UI 引擎的过程中,我一直坚持一个执念:零图片 (Zero Images)

与其堆砌几十 MB 的 PNG 资源文件,不如用纯粹的代码去绘制每一个像素。不是为了追求包体积,18MB 内存占用的极致性能。

今天,攻克了 Native UI 开发中最基础、但也最考验底层功底的三大难关:高性能圆角像素级描边,以及原生窗口透明

一、 为什么“画个圆角”这么难?

在 Web 前端写一个 border-radius: 8px 是理所当然的事,但在 C++ 和 Direct2D 的底层世界里,这一切都需要从零构建。

在初期遇到了三个典型问题:

  1. 锯齿与发虚:直接调用的绘图指令在低分屏上惨不忍睹,1px 的边框要么模糊成 2px 的灰线,要么出现断裂。
  2. 肉圆皮尖:背景色成功变成了圆角,但外层的边框依然是直角,导致视觉上出现极不协调的尖锐“毛刺”。
  3. 不支持透明:默认的 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,而是通过渲染器统一封装 FillRoundedRectangleDrawRoundedRectangle

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 还要更多元化的图形支持。接下来的路线图包括:

  1. 矢量圆支持:通过 NFXCircle 实现真正的矢量圆绘制(雷达图、状态灯)。
  2. 动画系统:让边框颜色和透明度动起来,实现“呼吸”效果。(基本差不多了)
  3. 高级特效: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;
    }