Sciter.js指南 - 告别模糊与错位(桌面 GUI 开发中屏幕缩放与 DPI 适配)

451 阅读9分钟

这分享一些桌面开发与 Web 开发不同,桌面应用需要直接面对各种复杂的显示环境:不同的屏幕分辨率、操作系统级别的缩放设置、甚至多台 DPI 不同的显示器。本指南将帮助你理解这些挑战,并掌握使用 Sciter.js 构建能在各种环境下良好运行的桌面应用的实践方法。

1. 核心概念:理解屏幕单位与缩放

在深入代码之前,我们先要弄清楚几个关键概念:

  • 物理像素 (Physical Pixels, ppx):

    • 是什么? 这是你显示器硬件上实际存在的、最小的发光点。一个 1920x1080 分辨率的屏幕,横向就有 1920 个物理像素点。
    • 问题在哪? 如果我们直接用物理像素来定义界面元素的大小(比如一个按钮宽 100 ppx),那么在高分辨率(高 DPI)屏幕上,这个按钮会显得非常小,因为单位面积内的物理像素点更多了。反之,在低分辨率屏幕上可能又太大。这导致界面在不同设备上看起来大小不一,体验很差。
  • DPI/PPI (Dots/Pixels Per Inch)):

    • 是什么? 表示每英寸长度上有多少个像素点。DPI 越高,意味着屏幕显示越精细。现在很多笔记本电脑和显示器都是高 DPI 屏幕 (例如 4K 显示器)。
    • 为什么重要? 为了在高 DPI 屏幕上正常显示内容(而不是让所有东西都缩得很小),操作系统(如 Windows 和 macOS)引入了缩放 (Scaling) 功能。
  • 操作系统缩放 (OS Scaling):

    • 是什么? Windows 的“缩放与布局”设置(如 100%, 125%, 150%)或 macOS 的“显示”设置(如“默认”、“缩放”)允许用户放大或缩小整个操作系统的界面元素,使其在不同 DPI 的屏幕上保持大致相同的物理观看尺寸。
    • 开发者如何应对? 应用程序需要“感知”并响这种缩放,否则在高缩放比例下,界面可能会变得模糊或布局错乱。
  • 逻辑像素 / 设备无关像素 (Device Independent Pixels, dipdp):

    • 是什么? 这是解决物理像素问题的关键!它是一种抽象的虚拟的像素单位。它的设计目标是:无论屏幕的物理 DPI 或操作系统的缩放设置如何,1 dip 在屏幕上对应的物理尺寸大致保持不变
    • Sciter.js 的魔法: Sciter.js 默认将 CSS 中的 px 单位视为 dip!具体来说,Sciter (和许多其他 UI 框架,如 Windows UWP, Android) 定义 1 dip ≈ 1/96 英寸。这意味着在一个标准的 96 DPI 显示器上,1 dip 正好等于 1 个物理像素。在一个 192 DPI (或 96 DPI 但设置了 200% 缩放) 的显示器上,1 dip 会自动映射为 2x2 = 4 个物理像素。
    • 好处: 你只需要使用 dip (或者说,Sciter 里默认的 px) 来设计你的界面,Sciter 会自动为你处理好在不同 DPI 和缩放设置下的适配问题,让你的按钮、文字在各种屏幕上看起来大小都差不多。
  • devicePixelRatio:

    • 是什么? 它告诉你当前环境下,1 个 CSS 逻辑像素 (dip) 等于多少个物理像素 (ppx)。
      • 100% 缩放 / 96 DPI: devicePixelRatio = 1.0
      • 150% 缩放 / 144 DPI: devicePixelRatio = 1.5
      • 200% 缩放 / 192 DPI: devicePixelRatio = 2.0
    • 用途: 虽然 Sciter 会自动处理缩放,但有时你可能需要获取这个比例来进行一些精密的计算或与需要物理像素的系统 API 交互。
    • sciter 如何利用它实现自动缩放: 当你在 CSS 中写 width: 100px; (Sciter 默认视 px 为 dip),Sciter 在渲染时会内部查询当前的 devicePixelRatio。如果 devicePixelRatio 是 1.5,Sciter 就知道需要分配 100 * 1.5 = 150 个物理像素 (ppx) 的宽度来绘制这个元素。这个过程是自动发生的,开发者无需手动计算。

2. 实践一:拥抱 dip,告别 ppx 烦恼

核心原则: 在你的 CSS 和 JavaScript 代码中,始终优先使用 dip 单位 (在 Sciter 中,直接用 px 就行,因为它默认就是 dip) 来定义尺寸、边距、字体大小等。

CSS 示例:

/* style.css */
body {
  font-size: 14px; /* Sciter 中 px 默认是 dip,字体大小会自动缩放 */
  margin: 0;
  padding: 0;
}

.container {
  width: 600px;  /* 逻辑像素,会自动缩放 */
  height: 400px; /* 逻辑像素,会自动缩放 */
  padding: 20px; /* 逻辑像素,会自动缩放 */
  border: 1px solid black; /* 边框宽度也会自动缩放 */
}

button {
  width: 120px;
  height: 32px;
  font-size: 1em; /* 相对单位,基于父元素的字体大小 */
  margin: 10px;
  padding: 5px 10px;
}

/* 只有在极少数需要精确物理像素对齐的场景才考虑 ppx */
.pixel-perfect-thing {
   width: 1ppx; /* 明确指定使用物理像素 */
   height: 100ppx;
   background: red;
}

JavaScript 示例:

Sciter 的 JavaScript 引擎支持直接使用带单位的数值字面量。

// main.js

// 直接使用带单位的字面量,px 默认是 dip
const defaultWidth = 600px;
const defaultHeight = 400px;
const explicitDipWidth = 600dip; // 和 600px 等效

console.log(`Default width: ${defaultWidth}`); // 输出类似: 600px

// 获取元素并设置尺寸 (使用 dip)
document.ready = function() {
  const myElement = document.getElementById("my-element");
  if (myElement) {
    myElement.style.width = 150px; // 设置为 150 dip
    myElement.style.height = 75dip; // 显式使用 dip
  }

  // 读取元素的 dip 尺寸
  const rect = myElement.state.box("rect", "border", "window", false); 
  console.log(`Element DIP Rect: [${rect}]`);

  // 如果确实需要物理像素尺寸
  const ppxRect = myElement.state.box("rect", "border", "window", true); // 加 true 获取 ppx
  console.log(`Element PPX Rect: [${ppxRect}]`);

  // 读取 devicePixelRatio
  console.log(`Current devicePixelRatio: ${devicePixelRatio}`);
}

3. 实践二:使用 Flex (*) 实现响应式布局

桌面应用的窗口大小通常是可以改变的。固定像素宽度的布局在窗口缩放时体验很差。Sciter 提供了强大的 flow 属性和 flex 单位 (*) 来轻松创建响应式布局。

核心原则: 使用 flow 属性定义容器内子元素的排列方式 (水平、垂直、网格等),并结合 * 单位 (flex unit) 让子元素自动分配可用空间。

CSS 示例:

/* style.css */
.toolbar {
  flow: horizontal; /* 子元素水平排列 */
  border-bottom: 1px solid #ccc;
  padding: 5px;
  height: 40px; /* 固定高度 */
}

.toolbar button {
  width: auto; /* 宽度由内容决定 */
  margin-right: 5px;
}

.toolbar .spacer {
  width: 1*; /* 占据所有剩余空间,把右侧按钮推到最右边 */
}

.main-content {
  flow: horizontal; /* 主区域水平排列 */
  height: 1*; /* 占据父容器所有剩余的垂直空间 */
}

.sidebar {
  width: 200px; /* 固定宽度侧边栏 */
  height: 100%; /* 高度撑满父容器 */
  background: #f0f0f0;
  overflow: auto;
  padding: 10px;
}

.content-area {
  width: 1*; /* 占据所有剩余的水平空间 */
  height: 100%; /* 高度撑满父容器 */
  background: #fff;
  overflow: auto;
  padding: 10px;
}

这个例子展示了如何创建一个经典的工具栏+侧边栏+主内容区的布局,无论窗口如何缩放,主内容区和侧边栏都会自动调整。

4. 实践三:正确处理坐标

在 JavaScript 中与位置和鼠标事件打交道时,要特别注意坐标系和单位。

核心原则:

  • 理解不同 API 返回/需要的坐标系(相对于元素、窗口还是屏幕)。
  • 注意单位!大部分 Sciter API 使用 dip,但屏幕绝对坐标 (screenX/screenY) 使用的是 ppx。鼠标事件中的 event.x/y, event.clientX/Y, event.windowX/Y 均使用 dip。只有 event.screenX/Y (屏幕绝对坐标) 使用 ppx。
  • API 默认返回 dip: 大多数获取元素几何信息 (位置、尺寸) 的 Sciter API,如 element.state.box() 和 window.box(),默认情况下返回以 dip 为单位的值。
  • 如果需要,显式请求 ppx: 这些 API 通常提供一个布尔参数 (通常是最后一个参数,文档中称为 asPpx)。当此参数设置为 true 时,API 会返回以物理像素 (ppx) 为单位的值。

JavaScript 示例:

// main.js
document.on("mousemove", function(event) {
  const target = event.target;

  // 1. 相对于事件目标元素 (currentTarget) 的坐标 (dip)
  const elementX = event.x;
  const elementY = event.y;
  // console.log(`Mouse relative to element: (${elementX}px, ${elementY}px)`);

  // 2. 相对于窗口客户区的坐标 (dip)
  const clientX = event.clientX;
  const clientY = event.clientY;
  // console.log(`Mouse relative to window client area: (${clientX}px, ${clientY}px)`);

  // 3. 相对于屏幕左上角的坐标 (注意:是物理像素 ppx!)
  const screenX = event.screenX;
  const screenY = event.screenY;
  // console.log(`Mouse relative to screen: (${screenX}ppx, ${screenY}ppx)`);

  // --- 获取尺寸和位置 ---
  // 默认获取 DIP 值
   const [x_dip, y_dip, w_dip, h_dip] = element.state.box("rectw", "border", "window");
  console.log(`Element Box (DIP): x=${x_dip}, y=${y_dip}, w=${w_dip}, h=${h_dip}`);
  // => 例如: x=100, y=50, w=120, h=30 (逻辑像素)

  // 显式获取 PPX 值 (最后一个参数为 true)
  const [x_ppx, y_ppx, w_ppx, h_ppx] = element.state.box("rectw", "border", "window", true);
  console.log(`Element Box (PPX): x=${x_ppx}, y=${y_ppx}, w=${w_ppx}, h=${h_ppx}`);
  // => 在 150% 缩放 (devicePixelRatio=1.5) 下可能输出: x=150, y=75, w=180, h=45 (物理像素)
  
  // --- 设置尺寸 ---
  // 推荐使用带单位的字面量 (px 或 dip)
  element.style.width = 150px; // 设置为 150 dip
  element.style.margin = 10dip; // 显式使用 dip

  // --- 获取 devicePixelRatio ---
  const currentRatio = devicePixelRatio; // 获取当前窗口的 ratio
  console.log(`Current devicePixelRatio: ${currentRatio}`);

  const primaryScreenRatio = Window.screenBox(0, "devicePixelRatio"); // 获取主显示器的 ratio
  console.log(`Primary screen devicePixelRatio: ${primaryScreenRatio}`);

  // --- 处理鼠标事件 ---
  document.on("click", function(event) {
    console.log(`Clicked at window coords (DIP): (${event.clientX}px,        ${event.clientY}px)`);
    console.log(`Clicked at screen coords (PPX): (${event.screenX}ppx, ${event.screenY}ppx)`);
  });
}

5. 实践四:多显示器与 DPI 感知

现代桌面环境经常涉及多个显示器,这些显示器的分辨率和 DPI 可能各不相同。虽然 Sciter 的 dip 机制能自动处理大部分缩放问题,但有时你的应用可能需要更精细的控制或信息。

核心原则:

  • 使用 Sciter API 获取显示器信息。
  • 移动窗口时,优先使用 dip 坐标 (window.moveTo) 以获得跨 DPI 的一致性。

JavaScript 示例:

// main.js

// 获取显示器数量
const screenCount = Window.screens;
console.log(`Number of screens: ${screenCount}`);

// 获取当前窗口所在的显示器索引
const currentScreenIndex = Window.this.screen;
console.log(`Window is on screen: ${currentScreenIndex}`);

// 获取当前显示器的信息
if (currentScreenIndex >= 0 && currentScreenIndex < screenCount) {
  // 获取工作区大小 (排除任务栏等区域, dip)
  const [wx, wy, ww, wh] = Window.screenBox(currentScreenIndex, "workarea", "rectw", false); // false 表示 dip
  console.log(`Screen ${currentScreenIndex} workarea (dip): [${wx},${wy},${ww},${wh}]`);

  // 获取 devicePixelRatio
  const ratio = Window.screenBox(currentScreenIndex, "devicePixelRatio");
  console.log(`Screen ${currentScreenIndex} devicePixelRatio: ${ratio}`);

  // 获取物理分辨率 (ppx)
  const [px, py, pw, ph] = Window.screenBox(currentScreenIndex, "frame", "rectw", true); // true 表示 ppx
  console.log(`Screen ${currentScreenIndex} physical resolution (ppx): [${px},${py},${pw},${ph}]`);
}

// 示例:将窗口移动到主显示器 (通常是索引 0) 的中央
function centerWindowOnPrimaryMonitor() {
  const primaryScreenIndex = 0; // 假设 0 是主显示器
  if (primaryScreenIndex < screenCount) {
    // 获取主显示器工作区大小 (dip)
    const [wx, wy, ww, wh] = Window.screenBox(primaryScreenIndex, "workarea", "rectw", false);

    // 获取当前窗口大小 (dip)
    const [winW, winH] = Window.this.box("dimension", "client", "self", false);

    // 计算居中位置 (dip)
    const targetX = wx + (ww - winW) / 2;
    const targetY = wy + (wh - winH) / 2;

    // 移动窗口 (moveTo 使用 dip 坐标)
    Window.this.moveTo(primaryScreenIndex, targetX, targetY);
    console.log(`Moved window to center of screen ${primaryScreenIndex} at (${targetX}px, ${targetY}px)`);
  }
}

// document.ready = function() { ... centerWindowOnPrimaryMonitor(); ... };

6. 实践五:跨环境测试!测试!测试!

跨环境测试

理论和实践总有差距。确保你的应用在真实环境中表现良好的唯一方法就是测试。

核心原则: 在尽可能多的环境下测试你的应用。

  • Windows:
    • 测试不同的显示缩放比例:100%, 125%, 150%, 175%, 200% 等。
    • 连接多个显示器,并将它们的缩放比例设置为不同值。
    • 在不同显示器之间拖动窗口,观察缩放是否正确、布局是否混乱。
  • macOS:
    • 在非 Retina 和 Retina 显示器上测试。
    • 测试不同的“缩放”设置。
  • Linux: (如果需要支持)测试常见的桌面环境和不同的显示设置。

观察字体是否清晰、图标和图片是否缩放得当、布局是否保持一致、坐标计算是否准确。

默认 px 即 dip?确认与配置

  • 文档默认行为: 根据 docs/md/CSS/units/dimentional.md 的描述,Sciter 默认将 CSS 中的 px 单位视为 dip (1px === 1dip)。这极大地简化了 DPI 适配。

  • 上面的行为被配置: “depends on engine configuration”(取决于引擎配置)。可以通过配置来改变 px 的默认行为,使其等同于 ppx。

  • 下图是缩放 200% 的时候界面测试的结果。

image.png

总结

构建能适应多样化屏幕环境的 Sciter.js 桌面应用,关键在于理解并利用其基于设备无关像素 (dip) 的缩放机制。核心实践包括:

  1. 理解 px 默认行为: 默认 px 等同于 dip,利用此特性简化开发。若遇不符,检查项目配置。优先选用 dip 或默认 px。
  2. 理解 devicePixelRatio: 认识到它是 Sciter 实现自动缩放的内部桥梁,并在需要时通过 API 查询它。
  3. 响应式布局: 使用 flow 和 Flex (*) 单位。
  4. 精确处理 JS 坐标: 掌握 API 返回 dip 的默认行为,知道如何获取 ppx,并注意事件坐标的单位。
  5. 多显示器感知: 利用相关 API 处理多屏场景。
  6. 全面测试: 在各种真实环境中验证应用表现。

遵循这些原则,你的 Sciter.js 应用将能更好地适应不同的用户环境,提供流畅一致的体验。

附测试文件

<!DOCTYPE html>
<html lang="zh-CN">

<head>
    <meta charset="UTF-8">
    <title>Sciter 单位测试</title>
    <style>
        html {
            overflow: auto;
        }

        body {
            font-family: "Microsoft YaHei", sans-serif;
            margin: 20px;
        }

        h1,
        p {
            text-align: center;
            margin-bottom: 10px;
        }

        code {
            background-color: #eee;
            padding: 2px 4px;
            border-radius: 3px;
            font-family: monospace;
        }

        .info {
            margin-bottom: 20px;
            padding: 15px;
            background-color: #f8f8f8;
            border: 1px solid #ddd;
            text-align: left;
        }

        .info strong {
            display: inline-block;
            min-width: 40px;
            font-weight: bold;
        }

        .info p {
            text-align: left;
            margin-bottom: 5px;
        }

        /* 修改 test-container 使用水平流布局 */
        .test-container {
            flow: horizontal;
            /* Sciter 水平流布局 */
            margin-bottom: 25px;
            padding: 10px;
            border: 1px dashed #ccc;
        }

        /* 左右两侧共同样式 */
        .test-content,
        .size-tag-container {
            padding: 0;
            height: auto;
        }

        /* 左侧内容容器 */
        .test-content {
            width: 180px;
            /* 固定左侧宽度 */
            margin-right: 15px;
        }

        /* 右侧整体容器 */
        .size-tag-container {
            width: 180px;
            /* 固定右侧宽度 */
        }

        /* 两侧的标题样式统一 */
        .test-content-title,
        .size-tag-title {
            display: block;
            font-weight: bold;
            margin-bottom: 8px;
            font-size: 14px;
        }

        .box,
        .size-tag {
            height: 80px;
            padding: 10px;
            border: 2px solid black;
            font-size: 13px;
            line-height: 1.5;
            overflow: hidden;
            box-sizing: border-box;
        }

        .default-px {
            width: 150px;
            background-color: lightblue;
        }

        .explicit-dip {
            width: 150dip;
            background-color: lightgreen;
        }

        .explicit-ppx {
            width: 150ppx;
            background-color: lightcoral;
        }

        /* 右侧结果容器 */
        .size-tag {
            width: 100%;
            /* 使用100%填满容器 */
            background: #f0f0f0;
            border: 1px solid #ccc;
            border-radius: 3px;
        }

        /* 创建测量结果布局 */
        .size-tag-content p {
            margin: 2px 0;
            text-align: left;
        }
    </style>
</head>

<body>
    <h1>Sciter 单位测试:<code>dip</code> vs <code>ppx</code> vs 默认 <code>px</code></h1>

    <div class="info">
        <p>
            请尝试在操作系统的显示设置中更改<strong>缩放比例</strong>(例如调整为 100%, 125%, 150%, 200% 等),然后重新加载此页面观察下方三个方块的变化及测量结果。
        </p>
        <p>
            当前的 <code>devicePixelRatio</code>: <strong id="devicePixelRatioValue">?</strong>
        </p>
        <hr style="margin: 10px 0;" />
        <p>
            <strong>观察要点:</strong>
            在高缩放比例下 (devicePixelRatio > 1),默认 <code>px</code><code>dip</code> 方块应比 <code>ppx</code> 方块宽。
        </p>
    </div>
    <div>
        <div class="test-content-title">宽度设置为 <code>150px</code> (默认单位)</div>
        <div class="test-container">
            <div class="test-content">

                <div class="box default-px">
                    这是一段用于测试的中文文字,观察它在不同宽度下的换行表现。150px 默认。
                </div>
            </div>
            <div class="size-tag-container">

                <div class="size-tag" id="default-px-size">
                    <p>DIP: <span class="dip-size">测量中...</span></p>
                    <p>PPX: <span class="ppx-size">测量中...</span></p>
                </div>
            </div>
        </div>

    </div>
    <div>
        <div class="test-content-title">宽度设置为 <code>150dip</code> (设备无关像素)</div>
        <div class="test-container">
            <div class="test-content">

                <div class="box explicit-dip">
                    这是一段用于测试的中文文字,观察它在不同宽度下的换行表现。150dip。
                </div>
            </div>
            <div class="size-tag-container">

                <div class="size-tag" id="explicit-dip-size">
                    <p>DIP: <span class="dip-size">测量中...</span></p>
                    <p>PPX: <span class="ppx-size">测量中...</span></p>
                </div>
            </div>
        </div>
    </div>

    <div>
        <div class="test-content-title">宽度设置为 <code>150ppx</code> (物理像素)</div>
        <div class="test-container">


            <div class="test-content">
                <div class="box explicit-ppx">
                    这是一段用于测试的中文文字,观察它在不同宽度下的换行表现。150ppx。
                </div>
            </div>
            <div class="size-tag-container">
                <div class="size-tag" id="explicit-ppx-size">
                    <p>DIP: <span class="dip-size">测量中...</span></p>
                    <p>PPX: <span class="ppx-size">测量中...</span></p>
                </div>
            </div>

        </div>
    </div>

    <script|module>
        async function run() {
            console.log("run called");
            setTimeout(measureAllBoxes, 50);
        }

        document.on("ready", run);

        function measureAllBoxes() {
            console.log("measureAllBoxes called");

            const currentRatio = devicePixelRatio;
            const ratioDisplay = document.$("#devicePixelRatioValue");
            if (ratioDisplay) {
                ratioDisplay.innerText = currentRatio;
            }

            measureBox(".box.default-px", "default-px-size");
            measureBox(".box.explicit-dip", "explicit-dip-size");
            measureBox(".box.explicit-ppx", "explicit-ppx-size");
        }

        function measureBox(boxSelector, resultId) {
            console.log(`Measuring ${boxSelector}`);

            // 获取目标元素
            const targetElement = document.$(boxSelector);
            if (!targetElement) {
                console.error(`找不到目标元素 ${boxSelector}`);
                updateSizeTag(resultId, "未找到", "未找到");
                return;
            }

            try {
                // 获取 PPX 尺寸 (加 true 参数)
                const ppxBox = targetElement.state.box("rectw", "border", "window", true);
                const ppxWidth = ppxBox[2]; // w_ppx
                const ppxHeight = ppxBox[3]; // h_ppx
                console.log(`${boxSelector} PPX尺寸:`, ppxWidth, ppxHeight);

                // 获取 DIP 尺寸
                const dipBox = targetElement.state.box("rectw", "border", "window", false);
                const dipWidth = dipBox[2]; // w_dip
                const dipHeight = dipBox[3]; // h_dip
                console.log(`${boxSelector} DIP尺寸:`, dipWidth, dipHeight);

                // 格式化尺寸文本
                const dipText = `宽=${dipWidth.toFixed(1)}, 高=${dipHeight.toFixed(1)}`;
                const ppxText = `宽=${ppxWidth}, 高=${ppxHeight}`;

                // 更新标签显示
                updateSizeTag(resultId, dipText, ppxText);

            } catch (error) {
                console.error(`测量 ${boxSelector} 时出错:`, error);
                updateSizeTag(resultId, "错误", "错误");
            }
        }

        // 辅助函数:更新尺寸标签内容
        function updateSizeTag(tagId, dipText, ppxText) {
            const container = document.$(`#${tagId}`);
            if (!container) return;

            // 更新 DIP 尺寸
            const dipElement = container.$(".dip-size");
            if (dipElement) dipElement.textContent = dipText;

            // 更新 PPX 尺寸
            const ppxElement = container.$(".ppx-size");
            if (ppxElement) ppxElement.textContent = ppxText;
        }
    </script>
</body>

</html>