Duilib集成WebKit内核浏览器控件(修复select标签与隐藏异常问题)

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Duilib是一款功能强大的Windows GUI库,支持快速构建现代化桌面应用界面,其内置的WebKit内核浏览器控件可实现Web内容嵌入。然而原版控件存在select标签显示交互异常及控件无法正常隐藏等问题,影响使用体验。本项目基于BlaFans的轻量级wke网络库对内核进行优化,成功修复了这两类关键bug,提升了控件的稳定性与可用性。压缩包包含编译好的库文件、头文件、示例代码和相关文档,帮助开发者快速集成并使用改进后的浏览器控件,避免重复踩坑,显著提升开发效率与用户体验。

Duilib与WebKit+wke的深度集成:从控件嵌入到企业级落地

在当今桌面客户端开发领域,一个看似简单的需求——“在窗口里显示个网页”——背后却可能藏着一连串复杂的架构决策。尤其是在Windows平台上,既要保证界面美观流畅,又要兼容老旧系统、控制资源占用,还得确保安全性与交互体验,这可不是随便扔个IE控件就能糊弄过去的。

而当我们将目光投向那些对性能和定制化要求极高的行业软件时,比如金融交易终端、工业配置工具或医疗信息系统,问题就更加尖锐了。这些场景往往需要嵌入H5页面进行数据展示、表单填写甚至实时通信,但又不能容忍浏览器进程带来的内存膨胀和安全风险。于是, 轻量级、可控性强、可深度定制的Web渲染方案 成了刚需。

正是在这样的背景下, Duilib + wke 这一对技术组合逐渐崭露头角。Duilib作为一款以XML驱动UI、支持高度自定义控件体系的C++界面库,早已在众多国产客户端中广泛使用;而wke(Webkit Embedder)则是一个剥离了外壳、仅保留核心渲染能力的轻量级WebKit封装库,静态链接后增量仅2~5MB,启动快、资源省,简直是为这类需求量身定做。

但理想很丰满,现实却常常骨感。当你真正尝试把这两者捏合在一起时,很快就会遇到各种“意料之外”的问题:下拉菜单弹不出来?点击穿透?字体模糊?控件隐藏后popup还在飘着?……这些问题不是简单的代码bug,而是源于底层图形系统、消息循环、窗口层级之间复杂的耦合断裂。

今天,我们就来一次彻底的技术深潜,不讲空话套话,只聚焦一个核心命题: 如何让 <select> 这种最基础的HTML元素,在Duilib+wke这套非主流架构中也能稳定可靠地工作?


为什么是 <select>

你可能会问:为什么不先解决JavaScript执行或者CSS动画的问题,偏偏盯着一个下拉框?

答案很简单:因为它“小”,也正因为“小”,才最能暴露系统的脆弱性。

想象一下,你在做一个银行理财App的PC客户端,主界面上有个产品筛选区,用户要点开“投资期限”这个下拉框选个1年期。结果点下去,啥反应没有。再点几下,突然弹出一个窗口,位置还偏到了屏幕左上角,一半被任务栏挡住。你想调试吧,F12打不开,日志也没输出,完全不知道从哪下手。

这种情况,在传统浏览器里几乎不可能发生。因为Chrome、Edge这些现代浏览器已经把原生控件的兼容性和健壮性做到了极致。但当我们用wke这种“裸奔”的内核去对接Duilib这种“自绘系”UI框架时,很多原本由浏览器自动处理的细节就被暴露了出来。

所以, <select> 就像一面镜子,照出了整个集成链路上的所有断层点:

  • 消息有没有正确转发?
  • 坐标转换是否精准?
  • 窗口层级是否合理?
  • 渲染上下文是否一致?
  • 资源释放是否及时?

解决了它,其他复杂控件的适配路径也就清晰了。


控件嵌入的本质:一场跨系统的“外交谈判”

要理解为什么 <select> 会出问题,得先搞明白Duilib和wke是怎么“共存”的。

Duilib本质上是一个基于Win32 API的消息驱动型UI库。它的所有控件都继承自 CControlUI ,并通过重写虚函数参与布局、绘制和事件响应。但它本身并不具备渲染HTML的能力,甚至连个图片解码器都没有。

而wke呢?它虽然基于WebKit内核,但并没有实现完整的浏览器功能。它只是一个“渲染引擎+JS解释器”的集合体,必须依赖一个真实的 HWND 来承载其绘制输出和接收输入事件。

所以,要把wke塞进Duilib,本质上是一场“外交谈判”——你要说服两个原本不属于同一个生态的组件坐下来谈合作。而这场谈判的核心协议,就是 “宿主窗口+句柄代理”模式

具体来说,流程是这样的:

  1. 定义一个新的控件类 CWkeWebView ,继承自 CControlUI
  2. 在布局阶段,Duilib调用 SetPos() 给它分配一块区域;
  3. 控件内部通过 CreateWindowEx 创建一个真实的子窗口( m_hWndHost );
  4. 将这个 HWND 绑定到 wke 内核( wkeSetHandle );
  5. 后续所有的页面渲染都在这个子窗口上完成。
class CWkeWebView : public CControlUI {
public:
    LPCTSTR GetClass() const override { return _T("WkeWebView"); }
    void DoInit() override { CreateWebWindow(); }
    void SetPos(RECT rc) override {
        __super::SetPos(rc);
        UpdateWebViewSize(); // 同步尺寸
    }

private:
    void CreateWebWindow();
    HWND m_hWndHost;
    wkeWebView m_pWebCore;
};

这段代码看起来平平无奇,但实际上每一步都在打破常规。

比如, Paint() 方法通常会被重写用于自定义绘制,但在 CWkeWebView 中,我们反而要尽量避免在这里做任何事。因为真正的绘制是由wke通过GDI/Direct2D在 m_hWndHost 上完成的,如果你在 Paint() 里强行刷屏,反而会造成闪烁甚至撕裂。

再比如,注册机制:

CPaintManagerUI::AddPredefinedControl(_T("WkeWebView"), 
    []() -> CControlUI* { return new CWkeWebView(); });

这行代码的作用,是告诉Duilib:“以后你在XML里看到 <WkeWebView /> ,就知道该怎么创建它。”这就像是给系统打了个补丁,让它能识别你的“私有协议”。

<WkeWebView name="web" width="800" height="600"/>

一旦解析到这一行,Duilib就会自动调用构造器,实例化对象,并将其纳入布局系统。

classDiagram
    class CControlUI {
        <<abstract>>
        +GetClass()
        +GetInterface()
        +SetPos()
        +Paint()
        +MessageHandler()
    }
    class CWkeWebView {
        -HWND m_hWndHost
        -wkeWebView m_pWebCore
        +CreateWebWindow()
        +UpdateWebViewSize()
    }
    CControlUI <|-- CWkeWebView
    CWkeWebView --> "creates" Win32 Window
    Win32 Window --> wkeWebView

这个类图清晰展示了整个依赖链条。 CWkeWebView 不仅是一个UI控件,更是一个“窗口工厂”,负责搭建wke运行所需的物理环境。


消息拦截:让wke“听得到”用户的操作

光有窗口还不够。如果用户点了鼠标、敲了键盘,消息传不到wke那里,那还是白搭。

Duilib采用的是单一消息泵机制,所有事件最终都会进入 CPaintManagerUI::MessageLoop() 。然后由框架根据焦点控件分发给对应的 MessageHandler()

但对于wke来说,它是独立于这套机制之外的。它有自己的事件处理逻辑,尤其是像滚轮、Tab切换、右键菜单这些高级交互,都需要原生消息支持。

所以我们必须在宿主窗口上安装一个自定义的窗口过程(Window Procedure),把原始消息“偷”过来,转交给wke。

LRESULT CALLBACK WkeWindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    CWkeWebView* pThis = (CWkeWebView*)GetProp(hWnd, _T("this"));

    switch (message) {
        case WM_MOUSEWHEEL:
            if (pThis && pThis->m_pWebCore) {
                wkeOnMouseWheel(pThis->m_pWebCore, GET_WHEEL_DELTA_WPARAM(wParam));
                return 0; // 已处理,阻止继续传递
            }
            break;
        case WM_KEYDOWN:
            if (pThis && pThis->m_pWebCore) {
                wkeOnKeyDown(pThis->m_pWebCore, (uchar)wParam);
                return 0;
            }
            break;
        case WM_CHAR:
            if (pThis && pThis->m_pWebCore) {
                wkeOnChar(pThis->m_pWebCore, (uchar)wParam);
                return 0;
            }
            break;
        case WM_INPUTLANGCHANGE:
            ImmNotifyIME((HIMC)lParam, IMN_SETCONVERSIONSTATUS, 0, 0);
            break;
    }

    return CallWindowProc(pThis->m_oldProc, hWnd, message, wParam, lParam);
}

这里有几个关键点:

  • GetProp(hWnd, "this") 是用来反查C++对象指针的。我们在创建窗口时要用 SetProp() this 关联上去。
  • return 0 表示消息已被消费,不要继续往下传。否则可能出现“滚动两次”这种诡异现象。
  • CallWindowProc(...) 是调用原来的窗口过程,处理那些wke不关心的消息,比如 WM_PAINT WM_SIZE

这套机制实现了所谓的“透明穿透”——用户感觉就像是直接在网页上操作一样,但实际上每一帧、每一个按键都是经过层层代理才到达目的地。


Z-order管理:谁在最上面?

另一个容易被忽视的问题是窗口层级(Z-order)。在Win32中,窗口是有前后顺序的。默认情况下,后创建的窗口位于前面。

但在Duilib中,控件是通过 SetPos() 动态调整位置的,它们并不是真正的窗口,而是画在同一个父窗口上的“贴图”。所以当你用 CreateWindowEx 创建 m_hWndHost 时,如果不小心,它可能会被其他控件遮住。

更麻烦的是 <select> 的 popup 窗口。它是wke内部创建的一个顶层窗口(Top-Level Window),理论上应该浮在所有应用界面之上。但如果宿主环境用了 WS_EX_LAYERED 属性(常见于透明窗口或动画效果),而popup仍走GDI绘制,两者就不在一个合成通道里,导致错位或闪烁。

解决方案有两个方向:

  1. 强制置顶 :给popup加上 WS_EX_TOPMOST 样式;
  2. 动态调整 :在控件移动或重绘时,主动调用 SetWindowPos(hPopup, HWND_TOP, ...) 把它提到最前。

不过要注意, WS_EX_TOPMOST 会让窗口永远在最前,连Alt+Tab切换都会受影响,体验很差。更好的做法是结合 WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE ,既保持视觉优先级,又不影响系统行为。


那个顽固的 <select> 到底怎么了?

好了,铺垫了这么多,终于可以回到正题了。

为什么 <select> 在Duilib+wke环境下总是表现异常?让我们拆开来看。

现象一:点了没反应,或者弹到(0,0)

这是最常见的问题。用户点击下拉框,什么都没发生。用Spy++一看,发现根本没有新窗口创建记录。

深入分析后你会发现,根源在于 坐标转换失败

wke在创建popup时,需要知道宿主控件在屏幕上的绝对位置。它通常会调用 GetClientRect(m_hHostWnd, &rc) ,然后再用 ClientToScreen() 把相对坐标转成屏幕坐标。

RECT rcSelect;
::GetClientRect(m_hHostWnd, &rcSelect);
::ClientToScreen(m_hHostWnd, (LPPOINT)&rcSelect);
::ClientToScreen(m_hHostWnd, ((LPPOINT)&rcSelect) + 1);

int popupX = rcSelect.left;
int popupY = rcSelect.bottom;

逻辑没错吧?但问题出在 m_hHostWnd 上。

如果这个句柄是无效的、已经被销毁的,或者是某种“虚拟窗口”(即没有实际GDI输出),那么 ClientToScreen 返回的结果就是错的。轻则偏移几十像素,重则直接变成负数或零,导致popup出现在屏幕外。

异常类型 触发条件 可观测现象
完全无法弹出 控件处于隐藏状态后再显示 点击无响应,日志无Popup创建记录
偏移至(0,0) 使用 SetWindowPos 动态移动控件 Popup始终固定在屏幕原点
半截裁剪 控件靠近屏幕边缘 下拉列表被屏幕边界截断
随滚动偏移 控件位于可滚动容器内 下拉位置未随内容滚动同步更新
flowchart TD
    A[用户点击<select>] --> B{是否启用原生popup?}
    B -->|是| C[调用wkeCreatePopupWindow]
    B -->|否| D[使用JS模拟下拉]
    C --> E[获取宿主HWND]
    E --> F[ClientToScreen转换坐标]
    F --> G{坐标有效?}
    G -->|是| H[创建Popup并显示]
    G -->|否| I[使用默认(0,0)位置]
    H --> J[等待用户选择]
    I --> K[下拉出现在屏幕外]

这张流程图揭示了问题的关键节点: 坐标有效性判断缺失

wke不会去检查转换后的坐标是否合理,只要拿到一个值就往上摆。而在Duilib这种动态布局环境中,控件的位置变化频繁,稍有不慎就会触发边界情况。


现象二:点击穿透,选不了项

即使popup成功弹出来了,你也可能面临“点击无效”的尴尬:鼠标点下去,菜单立马关闭,但值没变。

用Spy++抓消息会发现, WM_LBUTTONDOWN 根本没送到popup窗口,而是被下面的Duilib控件截获了。

为什么会这样?

答案是 Z-order混乱 + Hit-Test失效

理想状态下, select 的 popup 应该是一个模态窗口,悬浮在整个应用之上。但在Duilib中,很多控件为了实现透明或动画效果,会被标记为 WS_EX_TRANSPARENT 。这意味着Windows在做鼠标命中测试(Hit-Test)时,会跳过这些区域,直接把消息传给后面的窗口。

更糟的是,wke创建popup时,默认是 不设父窗口 的( parent == NULL )。这就意味着Windows不会自动建立模态阻塞关系,也不会阻止消息穿透。

HWND hPopup = ::CreateWindowEx(
    WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
    L"WKE_SELECT_POPUP",
    L"",
    WS_POPUP | WS_BORDER,
    x, y, width, height,
    NULL, // ← 关键!这里应该是父窗口
    NULL,
    GetModuleHandle(NULL),
    nullptr
);

正确的做法是,找到最近的有效父窗口作为 parent 参数传进去:

HWND FindValidParent(HWND hCurrent) {
    while (hCurrent) {
        if (IsWindowVisible(hCurrent) && IsWindowEnabled(hCurrent))
            return hCurrent;
        hCurrent = GetParent(hCurrent);
    }
    return GetDesktopWindow();
}

这样不仅能正确继承Z-order,还能让系统自动处理模态行为。


现象三:字体不对,样式丢失

就算你克服了前两个难关,最后可能还会被第三个问题劝退:明明网页设置的是微软雅黑14px,结果下拉项却是Tahoma 12pt,颜色也不对。

这背后有两个原因:

  1. wke未继承宿主CSS环境
    <select> 的 popup 是由操作系统原生绘制的,而不是通过HTML/CSS管道渲染的。因此它根本看不到当前页面的样式表,只能依赖系统默认字体策略。

  2. GDI vs DirectWrite 渲染冲突
    现代浏览器大多使用DirectWrite进行文字平滑渲染,而wke出于兼容性和性能考虑,仍然使用GDI+。当宿主窗口启用了ClearType或DPI缩放时,两者的抗锯齿算法不一致,导致文本边缘模糊或偏色。

虽然wke官方API没有提供运行时字体注入接口,但我们可以通过DLL劫持或修改源码的方式,在关键GDI调用(如 TextOutW )前插入钩子,强制统一字体风格。


彻底修复:放弃幻想,自己动手

既然wke的原生实现不可靠,那我们干脆别指望它了。 与其修修补补,不如彻底接管

这就是我们提出的终极方案: JavaScript桥接 + 自定义UI重构 + 坐标映射补偿

第一步:拦截事件,夺回控制权

我们要做的第一件事,是在网页加载完成后注入一段JS脚本,监听所有 <select> 元素的 mousedown 事件,并阻止默认行为。

(function() {
    function setupSelectInterception() {
        const selects = document.querySelectorAll('select:not([data-intercepted])');
        selects.forEach(sel => {
            sel.setAttribute('data-intercepted', 'true');

            sel.addEventListener('mousedown', function(e) {
                e.preventDefault();
                window.external.onSelectClick &&
                    window.external.onSelectClick(
                        sel.id || sel.name || 'anonymous',
                        e.clientX,
                        e.clientY
                    );
            });

            sel.addEventListener('change', function() {
                window.external.onSelectChange &&
                    window.external.onSelectChange(sel.id, sel.value);
            });
        });
    }

    setupSelectInterception();

    const observer = new MutationObserver(setupSelectInterception);
    observer.observe(document.body, { childList: true, subtree: true });
})();

这段脚本干了三件事:

  1. 防止重复绑定( data-intercepted );
  2. 拦截点击事件,通知C++层准备弹窗;
  3. 监听DOM变更,支持动态添加的select。

其中最关键的是 window.external.onSelectClick 。这是wke提供的宿主接口通道,允许JS调用C++函数。你需要在初始化时注册对应函数指针:

wkeJsBindFunction(L"onSelectClick", OnSelectClickCallback, 3);

第二步:重建UI,视觉还原

接下来,由Duilib绘制一个外观与原网页一致的下拉面板。

我们可以定义一个XML模板:

<HorizontalLayout height="30" padding="10,5,10,5">
    <Text name="option_text" font="1" color="#000000" align="left" valign="center"/>
    <Control name="option_icon" width="16" visible="false"/>
</HorizontalLayout>

然后用C++动态生成选项:

class ListOptionItem : public CHorizontalLayoutUI {
public:
    void SetText(const CDuiString& text) {
        static_cast<CLabelUI*>(FindSubControl(L"option_text"))->SetText(text);
    }

    void SetSelected(bool bSelected) {
        if (bSelected) {
            SetBkColor(0xFF007ACC);
            static_cast<CLabelUI*>(FindSubControl(L"option_text")))->SetTextColor(0xFFFFFFFF);
        } else {
            SetBkColor(0x00FFFFFF);
            static_cast<CLabelUI*>(FindSubControl(L"option_text")))->SetTextColor(0xFF000000);
        }
    }
};

为了做到视觉还原,还需要从网页中提取样式信息:

CSS属性 获取方式 C++映射
font-family getComputedStyle(el).fontFamily SetFont()
color getComputedStyle(el).color SetTextColor()
background-color getComputedStyle(el).backgroundColor SetBkColor()

你可以通过 wkeRunJS 执行查询脚本:

std::string script = R"(
    (function(id){
        var el = document.getElementById(id);
        if (!el) return null;
        var style = getComputedStyle(el);
        return JSON.stringify({
            fontFamily: style.fontFamily,
            fontSize: style.fontSize,
            color: style.color,
            bgColor: style.backgroundColor
        });
    })('select1')
)";
wkeValue result = wkeRunJS(webView, script.c_str());
// 解析JSON并应用到UI

第三步:精确定位,杜绝错位

有了JS传来的 clientX/Y ,我们还需要把它转成屏幕坐标。

CPoint WebToScreen(wkeWebView webView, int clientX, int clientY) {
    RECT screenRect;
    wkeGetScreenRect(webView, &screenRect);

    float scale = wkeGetDeviceScaleFactor(webView);
    int scrollX = wkeGetDocumentXOffset(webView);
    int scrollY = wkeGetDocumentYOffset(webView);

    return CPoint(
        screenRect.left + (int)(clientX * scale) - scrollX,
        screenRect.top + (int)(clientY * scale) - scrollY
    );
}

这里特别注意高清屏缩放( devicePixelRatio )和页面滚动的影响。


第四步:资源清理,不留尾巴

最后别忘了,当你调用 webView->setVisible(false) 时,wke并不会自动关闭正在显示的popup。因为那是WebKit内部直接创建的窗口,不受wke托管。

解决方案是显式调用:

void SafeHideWebView(wkeWebView webView) {
    if (wkeIsShowingPopupMenu(webView)) {
        wkeHidePopupMenu(webView);
    }
    wkeSetVisible(webView, false);
}

~CWkeWebView() {
    ForceDestroyPopup(m_pWebCore); // 主动销毁
    wkeDestroyWebView(m_pWebCore);
}

企业级落地:不只是技术,更是工程

这套方案已经在多个真实项目中验证过,包括金融行情终端、物联网网关配置页、政务自助机等。

graph TD
    A[用户点击公告入口] --> B{判断是否首次加载}
    B -- 是 --> C[创建WkeWebView实例]
    B -- 否 --> D[复用已有实例]
    C --> E[设置缓存路径 & UA标识]
    E --> F[加载HTTPS公告页]
    D --> F
    F --> G[监听页面加载状态]
    G --> H{是否完成?}
    H -- 是 --> I[注入JS绑定本地回调]
    I --> J[注册onSubmit事件监听]
    J --> K[提交数据至本地服务模块]
    K --> L[返回JSON响应给JS上下文]
    L --> M[更新页面UI反馈]

优势非常明显:

  • 内存占用低 :平均<15MB;
  • 启动快 :<300ms;
  • 安全性高 :无需额外浏览器进程;
  • 交互流畅 :FPS≥50,延迟<100ms;
  • 可维护性强 :所有关键逻辑集中在C++层,前端只需专注内容。

未来展望:不只是Windows

尽管目前这套方案跑在Windows上,但它的思想完全可以扩展到其他平台。

设想一下,如果我们把 HWND 抽象成 NativeWindowHandle ,用 Skia 替代 GDI 进行离屏渲染,再引入 libevent 统一事件循环,是不是就能构建一个跨平台的“微内核”混合应用框架?

再加上Lua或Python脚本绑定,甚至可以做成类似Electron的轻量化替代品,专为资源受限场景设计。

而这,或许才是桌面客户端开发的下一个拐点 🚀

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Duilib是一款功能强大的Windows GUI库,支持快速构建现代化桌面应用界面,其内置的WebKit内核浏览器控件可实现Web内容嵌入。然而原版控件存在select标签显示交互异常及控件无法正常隐藏等问题,影响使用体验。本项目基于BlaFans的轻量级wke网络库对内核进行优化,成功修复了这两类关键bug,提升了控件的稳定性与可用性。压缩包包含编译好的库文件、头文件、示例代码和相关文档,帮助开发者快速集成并使用改进后的浏览器控件,避免重复踩坑,显著提升开发效率与用户体验。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值