写一个C++ addon支持语雀Win暗黑标题栏

915 阅读8分钟

背景

暗黑模式在语雀桌面端 1.8.0 版本开始接入,Windows 标题栏不支持暗黑模式这个问题从 1.8.0 开始就一直在困扰着我们。随着版本迭代,桌面端在不断优化暗黑模式的体验,关于暗黑模式的用户反馈也越来越多集中在 Windows 标题栏上,我随手摘几个近期的反馈(还有来自微信群和运营同学的反馈就不一一统计了)

这期间我们也一直在尝试各种方案也踩过很多坑,直到 2.3.0 我们终于可以说是解决了这个问题,并且把能力开源到了 Github 上:

Github - electron-windows-titlebar

先来个 demo 看看效果~

为什么要用到 C++ addon

相信大家看到这个文章标题会很困惑,给 Windows 标题栏改个暗黑有这么难吗,为啥还要用到 C++ addon?

别急,听我一个个讲完我们尝试过的办法,你就知道原因啦~

1. nativeTheme API 不支持 Win 标题栏暗黑

桌面端的窗口级别暗黑主题切换,是通过给 electron 的 nativeTheme.themeSource 指定 'dark'或者'light'来实现的。

然而,这个 API 并不支持 Windows 标题栏切暗黑颜色。我在之前分享的 Electron 20 值得关注的变化 里也写过,官方 API 一直没有解决这个问题。

我之前也一直非常希望 electron 官方能支持,但从现在来看,我猜测官方一直不支持的原因,大概率和兼容性有关,微软在 Windows 10 1809 版本开始引入暗黑主题,而 electron 还要支持 Win 10 更早的版本及 Win 7、8(兼容性在后文会详述)。

虽然 nativeTheme 不支持,但 BrowserWindow 有好几个 titlebar 样式的属性呀,不可以用吗?

是的,不可以

2. BrowserWindow 不支持 Win 修改 titlebar 样式

在 electron 官方的自定义窗口文档里,推荐了两种方式:自定义标题栏样式、window control overlay。

自定义标题栏样式

通过给 BrowserWindow 设置 titleBarStyle: customButtonsOnHover来自定义标题栏样式,但是!只支持 Mac

window control overlay

这个办法倒是 Windows 和 Mac 都支持:

const { BrowserWindow } = require('electron')
const win = new BrowserWindow({
  titleBarStyle: 'hidden',
  titleBarOverlay: {
    color: '#2f3241',
    symbolColor: '#74b1be',
    height: 60
  }
})

但是,看完效果,我和PD都沉默了😓

看了一下 Window Controls Overlay 的定义,确实是只有按钮那块区域,而且是覆盖在窗口内容上的,这不符合我们的审美和产品要求,所以也没采用。

不过这个方法也给了我们灵感,我们是不是可以自己画一个标题栏呢?

3. 自定义标题栏

没错,在第一版的 Github - electron-windows-titlebar 里,我们确实尝试了自己画一个标题栏组件来支持暗黑:

看起来效果挺不错的,那问题应该解决了?

实际上这个方案我们在 1.8.1 上线,收到大量反馈有bug和体验问题,在 1.8.2、1.8.3 修复后还是决定在 1.8.4 改回原生标题栏。原因有以下几点:

  • 体验没有原生标题栏好:没有按钮反馈感、颜色渐变动画等。
  • 点击反应没有原生快:ipc通信的轻微延迟,有些用户都感受到了。

  • 点击和拖拽区域:相信很多人都踩过 -webkit-app-region: drag; 无法点击的坑。虽然我们在之后修复了,但还有用户反馈拖拽没有原生标题栏灵敏。

  • 占用窗口内容:语雀桌面端有大量组件使用视窗高度,这种方式需要改动所有使用 vh 的样式。更重要的是,桌面端和 web 部分组件同构,且未来 web 新开发的组件都需要注意给桌面端的样式做诸如100vh - 60px的兼容,这就会给不熟悉桌面端的 web 同学埋下大坑,一漏掉可能内容就被遮挡了。

  • 没法处理异常当启动、渲染过程出现异常,用户遇到白屏,窗口都没办法关了。

综上,我们认为标题栏这种提供的窗口管理能力的应用级别功能,还是使用系统原生能力更靠谱

到这里,我们也是使出了浑身解数,还是没解决问题。

所以,就开始打起改 Windows 原生标题栏的主意,那么首选的,就是 C++ addon

怎么写 C++ addon

首先,我们要先知道理论上有没有可行的路,经过调研: 【调研】Windows自定义标题栏能力

发现确实有可行的办法:

DwmSetWindowAttribute(hWnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value)); 

DwmSetWindowAttribute 这个 API 支持给标题栏设置暗黑,兼容性也在可接受范围(后面展开讲)。微软官方也是推荐这种方式: Windows - Enable a Dark mode title bar for Win32 applications

理论可行,开始实践~

这里简单介绍一下怎么开发 C++ addon:

环境

Windows C++开发相关的,建议使用最新版本,可以减少很多解决环境问题的痛苦😄

  • Windows 11
  • Python 3.10
  • Visual Studio 2022
  • Node 18.12.1

我们主要是用 N-API 来进行 C++ addon 开发,在初始化仓库后,需要安装:

npm i node-gyp --save-dev
npm i node-addon-api
npm i bindings

开发

初始化项目

新增 ./binding.gyp 文件:

{
  "targets": [
    {
      "target_name": "electron-windows-titlebar",
      "sources": [
        "./src/cpp/main.cpp"
      ],
      "include_dirs": [
        "<!@(node -p "require('node-addon-api').include")"
      ],
      "dependencies": [
        "<!(node -p "require('node-addon-api').gyp")"
      ],
      "cflags!": [
        "-fno-exceptions"
      ],
      "cflags_cc!": [
        "-fno-exceptions"
      ],
      "msvs_settings": {
        "VCCLCompilerTool": {
          "ExceptionHandling": 1
        }
      },
      "defines": [
        "NAPI_DISABLE_CPP_EXCEPTIONS",
        "_HAS_EXCEPTIONS=1"
      ],
      "libraries": [],
    }
  ]
}
  • target_name:指定编译后的 node 二进制文件名
  • sources:C++ 入口文件

package.json 加入 scripts:

{
  "name": "electron-windows-titlebar",
  "version": "1.0.0",
  "description": "windows-style title bar component for Electron",
  "main": "index.js",
  "scripts": {
    "build": "node-gyp rebuild",
    "clean": "node-gyp clean"
  },
  "dependencies": {
    "bindings": "^1.5.0",
    "node-addon-api": "^3.0.0"
  },
  "devDependencies": {
    "node-gyp": "^9.3.0",
  }
}

写一个 Hello World

新建 src/cpp/main.cpp

#include <napi.h>

std::string hello(){
  return "Hello World";
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("hello", Napi::Function::New(env, hello));
  return exports;
}

NODE_API_MODULE(titlebar, Init);
  • #include<napi.h> 引入 N-API 头文件
  • exports.Set("hello", Napi::Function::New(env, hello)); 指定了对外暴露 API hello()
  • NODE_API_MODULE 注册模块入口函数为 Init

然后跑一下编译:

npm run build

./index.js 里引入编译好的 node 文件:

const titlebar = require('./build/Release/electron-windows-titlebar.node');
console.log('C++ addon', titlebar.hello());
module.exports = titlebar;

运行一下,就能看到 ‘C++ addon Hello World’ :

node index.js

这样我们完成了一个简单的C++ addon啦~

更多 N-API 用法和例子可以参考官方文档:node-addon-api

发布

之前 require('./build/Release/electron-windows-titlebar.node'); 这种方式不太优雅。

我们之前装的 bindings 模块没说作用,现在用上啦,bindings 能帮助模块找到原生的 .node 文件:

'use strict';

const pkgName = require('./package').name;
module.exports = require('bindings')(pkgName);

然后在 package.json 里加上,方便每次发布前自动编译,我们就可以愉快的发包啦~

"prepublishOnly": "npm run build",

支持暗黑的原理

来看看我们的核心代码:

#include <napi.h>
#include <dwmapi.h>
#include <VersionHelpers.h>

#pragma comment (lib, "dwmapi.lib")

/**
 * DWMWA_USE_IMMERSIVE_DARK_MODE = 20 is supported starting with Windows 10 20H2
 * ref: https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute
*/
static void changeTheme(Napi::Buffer<void *> wndHandle, bool isDark) {
  // step 1, check windows version
  if (!IsWindows10OrGreater()) {
    return;
  }
  const int DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19;
  const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
  // step 2, set window attribute to dark mode
  HWND hwnd = static_cast<HWND>(*reinterpret_cast<void **>(wndHandle.Data()));
  BOOL USE_DARK_MODE = isDark;
  DwmSetWindowAttribute(
    hwnd,
    DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1,
    &USE_DARK_MODE,
    sizeof(USE_DARK_MODE)
  );
  DwmSetWindowAttribute(
    hwnd,
    DWMWA_USE_IMMERSIVE_DARK_MODE,
    &USE_DARK_MODE,
    sizeof(USE_DARK_MODE)
  );
  // step 3, redraw the current window
  // ref: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowpos
  RECT rect;
  GetWindowRect(hwnd, &rect);
  SetWindowPos(hwnd, 0, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top + 1, SWP_DRAWFRAME|SWP_NOACTIVATE|SWP_NOZORDER);
}

void switchLightMode(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  if (info.Length() < 1 || !info[0].IsBuffer()) {
    Napi::TypeError::New(env, "hwnd buffer expected").ThrowAsJavaScriptException();
  }
  changeTheme(info[0].As<Napi::Buffer<void*>>(), false);
}

void switchDarkMode(const Napi::CallbackInfo& info) {
  Napi::Env env = info.Env();
  if (info.Length() < 1 || !info[0].IsBuffer()) {
    Napi::TypeError::New(env, "hwnd buffer expected").ThrowAsJavaScriptException();
  }
  changeTheme(info[0].As<Napi::Buffer<void*>>(), true);
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  exports.Set("switchLightMode", Napi::Function::New(env, switchLightMode));
  exports.Set("switchDarkMode", Napi::Function::New(env, switchDarkMode));
  return exports;
}

NODE_API_MODULE(titlebar, Init);
  • IsWindows10OrGreater() 判断 Windows 版本,因为微软在 Windows 10 1809 引入暗黑模式,所以 Windows 10 以下的版本直接返回。
  • changeTheme() 切换主题,通过调用 DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value));
    • hwnd 是当前窗口句柄,类型 Buffer。系统是通过窗口句柄来找的窗口对象的。
      • electron 提供了 win.getNativeWindowHandle() 来获取当前窗口系统原生句柄。
    • DWMWA_USE_IMMERSIVE_DARK_MODE是设置窗口暗黑的属性, Windows 10 1809 引入 DWMWA_USE_IMMERSIVE_DARK_MODE = 19未被官方文档记录,这个值在 Windows 10 20H1 开始改为了DWMWA_USE_IMMERSIVE_DARK_MODE = 20
    • &value是传入的参数,这里传入布尔值,true 为暗黑,false 为默认(浅色)
    • sizeof(value) 就是传入 value 的大小。
  • 在切换主题后,可以注意到还用了 SetWindowPos() 设置窗口位置 ,是因为在 Win 10 部分老版本里,设置完窗口样式并不会立马生效需要触发窗口重绘后才生效。

到这里,原理就讲完啦,是不是也不难理解的~

兼容性

这个方案支持的最小版本为 Windows 10 1809(build 17763),在此之前的版本切换暗黑不生效。因为微软是在这个版本开始引入的暗黑模式,在此之前无法支持。

根据最新的第三方数据,Win10 和 Win11 占比已接近87%:

对于语雀来说,在这个版本之前的用户量就更少,兼容性上可以接受。

此外,这些厂商都在使用这个方案支持 Windows 标题栏的暗黑模式:

  • Mozilla-central

hg.mozilla.org/mozilla-cen…

  • Microsoft Terminal

github.com/microsoft/t…

  • Eclipse

bugs.eclipse.org/bugs/show_b…

还有 C++ 库:

github.com/ysc3839/win…

所以可以不用过多担心兼容性问题。

怎么使用

欢迎有暗黑模式需求的 electron 开发同学使用我们的 Github - electron-windows-titlebar

使用方式也非常简单:

const windowTitleBar = require('electron-windows-titlebar');
const win = new BrowserWindow({
  width: 800,
  height: 600,
  title: 'addon demo',
})
const hwnd = win?.getNativeWindowHandle(); // 获取窗口原生句柄
const setDark = true;
if (hwnd) {
  setDark ? windowTitleBar.switchDarkMode(hwnd) : windowTitleBar.switchLightMode(hwnd);
}

遇到任何问题欢迎直接联系我,觉得有帮助的求 Github Star 🌟~

参考

本文搬运自我的语雀文章 写一个C++ addon支持语雀Win暗黑标题栏。欢迎关注我的语雀个人主页。同时欢迎关注我的掘金专栏,这个专栏将会持续分享学习各种前端/桌面端相关技术(Electron、node.js、V8、Chromium等)的新特性