在这里插入图片描述

一、为什么 PC 应用必须重视多显示器

在手机上,一块屏幕就是整个世界。但到了 PC 与平板桌面形态,多显示器几乎是生产力用户的标配:设计师把调色板拖到副屏,程序员把文档甩到左屏、终端铺满右屏,会议场景里把演示窗口推到投影仪所在的第二块 Display。对 HarmonyOS NEXT 的 PC / 2-in-1 设备而言,"能感知多块屏幕并正确适配"已经从加分项变成了基础能力。

本文聚焦一个此前三版系列文章都没有展开的方向:多显示器检测、显示密度(DPI)适配、以及窗口在显示器之间的迁移。我们刻意避开窗口的基础管理、鼠标键盘交互、文件拖拽、IPC、系统托盘与通知推送——那些内容已经在前几篇(20260711 窗口与交互、20260712 IPC、20260713 托盘与通知)中讲透。

下面所有的代码示例均基于 HarmonyOS NEXT(API 12+),使用 ArkTS,可直接落到工程里运行。核心依赖只有两个系统模块:@ohos.display@ohos.window,外加 AbilityContext 提供的 Configuration 监听。

先给出一个总览:我们要解决的三个问题分别是"有几块屏、每块屏长什么样"“在不同密度下画出来的东西尺寸对不对”“窗口能不能被搬到指定的一块屏上”。下面逐一拆解。


二、认识 display 模块与 Display 对象

HarmonyOS 把所有物理/逻辑屏幕抽象成 Display 对象,统一由 @ohos.display 模块管理。每一个 Display 都携带分辨率、刷新率、DPI、密度系数等关键字段。最常见的三个入口是:getDefaultDisplaySync() 拿到主屏(primary),getAllDisplays() 拿到全部屏幕,以及 getDisplayByIdSync(id) 按 id 精确定位。

注意 Sync 后缀的方法会在调用线程直接返回结果,适合在 UI 初始化阶段同步读取;而带 Promise 的异步版本更适合在热插拔回调里使用。下面这段工具类封装了最常用的读取逻辑。

// DisplayUtil.ets
import { display } from '@kit.ArkUI';

export interface ScreenSnapshot {
  id: number;
  name: string;
  width: number;
  height: number;
  refreshRate: number;
  densityDPI: number;
  densityPixels: number;
  scaledDensity: number;
  isPrimary: boolean;
  alive: boolean;
}

export class DisplayUtil {
  // 同步获取主屏(primary,directory === 0)
  static getPrimarySync(): ScreenSnapshot {
    const d = display.getDefaultDisplaySync();
    return DisplayUtil.toSnapshot(d);
  }

  // 同步获取全部屏幕
  static getAllSync(): ScreenSnapshot[] {
    return display.getAllDisplays().map((d) => DisplayUtil.toSnapshot(d));
  }

  // 按 id 精确定位
  static getByIdSync(id: number): ScreenSnapshot | undefined {
    const list = display.getAllDisplays();
    const found = list.find((d) => d.id === id);
    return found ? DisplayUtil.toSnapshot(found) : undefined;
  }

  private static toSnapshot(d: display.Display): ScreenSnapshot {
    return {
      id: d.id,
      name: d.name,
      width: d.width,
      height: d.height,
      refreshRate: d.refreshRate,
      densityDPI: d.densityDPI,
      densityPixels: d.densityPixels,
      scaledDensity: d.scaledDensity,
      isPrimary: d.directory === 0,
      alive: d.alive
    };
  }
}

小结一下:Display 是一个不可变快照,字段在一次读取时是一致的;但用户随时可能改分辨率或插拔屏幕,所以"一次性读取"只能作为初值,真正的健壮性来自监听。


三、多显示器热插拔监听

PC 场景的屏幕集合是动态的:插上 HDMI 扩展屏、拔掉 USB-C 显示器、合上笔记本盖子让内屏进入 OFF 状态……应用如果不能感知这些事件,就会出现"把窗口搬到一个已经不存在的屏"之类的尴尬。

display 模块通过 on / off 暴露了三类事件:'add'(新屏接入)、'remove'(屏幕移除)、'change'(已有屏幕的属性变化,比如改了分辨率)。回调参数就是对应的 Display 对象或 id。下面封装一个可复用的监听器。

// DisplayMonitor.ets
import { display } from '@kit.ArkUI';

export type DisplayEvent = 'add' | 'remove' | 'change';

export class DisplayMonitor {
  private handlers: Map<DisplayEvent, (d: display.Display) => void> = new Map();

  on(event: DisplayEvent, handler: (d: display.Display) => void): void {
    this.handlers.set(event, handler);
    display.on(event, handler);
  }

  off(event: DisplayEvent): void {
    const handler = this.handlers.get(event);
    if (handler) {
      display.off(event, handler);
      this.handlers.delete(event);
    }
  }

  // 在 Ability 销毁时统一清理,避免泄漏
  dispose(): void {
    this.handlers.forEach((handler, event) => {
      display.off(event, handler);
    });
    this.handlers.clear();
  }
}

使用方式非常直接:在 EntryAbilityonWindowStageCreate 里注册,在 onDestroydispose()。这里有一个工程经验——监听器一定要成对注册与注销,否则屏幕事件会持续回调到已经销毁的页面,轻则打印空指针,重则引发崩溃。

// 在页面或 Ability 中
private monitor: DisplayMonitor = new DisplayMonitor();

aboutToAppear(): void {
  this.monitor.on('add', (d) => {
    console.info(`屏幕接入: id=${d.id}, name=${d.name}, ${d.width}x${d.height}`);
    this.refreshScreenList();
  });
  this.monitor.on('remove', (d) => {
    console.warn(`屏幕移除: id=${d.id}`);
    this.refreshScreenList();
  });
  this.monitor.on('change', (d) => {
    console.info(`屏幕变化: id=${d.id}, refreshRate=${d.refreshRate}`);
    this.refreshScreenList();
  });
}

aboutToDisappear(): void {
  this.monitor.dispose();
}

热插拔监听是"感知"层。感知到之后,下一步是"理解"——这块屏到底支持哪些分辨率和刷新率?


四、显示器进阶信息:DisplayMode 与刷新率

一块现代显示器往往支持多种分辨率与刷新率组合,比如 4K@60Hz、4K@30Hz、1080p@120Hz。系统当前用的是其中某一个 DisplayModeDisplay 对象上的 refreshRatewidth/height 只反映"当前模式",而 display.getSupportedModes(id) 能列出"所有可选模式"。

DisplayMode 的结构很简单:idwidthheightrefreshRate。下面这段代码演示如何枚举某块屏的全部模式,并挑出刷新率最高的那一个,供用户做"性能模式"切换。

// DisplayModeUtil.ets
import { display } from '@kit.ArkUI';

export class DisplayModeUtil {
  // 列出某块屏支持的全部模式
  static async listModes(displayId: number): Promise<display.DisplayMode[]> {
    try {
      return await display.getSupportedModes(displayId);
    } catch (e) {
      console.error(`读取模式失败: ${(e as Error).message}`);
      return [];
    }
  }

  // 读取当前生效模式
  static async currentMode(displayId: number): Promise<display.DisplayMode | undefined> {
    return await display.getDisplayMode(displayId);
  }

  // 切换到指定模式(需要系统权限,普通应用多为只读)
  static async applyMode(displayId: number, modeId: number): Promise<boolean> {
    try {
      await display.setDisplayMode(displayId, modeId);
      return true;
    } catch (e) {
      console.error(`切换模式失败(需权限): ${(e as Error).message}`);
      return false;
    }
  }

  // 业务辅助:找出刷新率最高的模式 id
  static highestRefreshRateMode(modes: display.DisplayMode[]): display.DisplayMode | undefined {
    return modes.reduce((best, cur) =>
      cur.refreshRate > best.refreshRate ? cur : best, modes[0]);
  }
}

需要提醒一句:setDisplayMode 属于系统级能力,普通三方应用通常只有"读"权限。把它写进工具类只是为了展示完整链路——实际产品里往往只是把可选模式展示给用户,真正的切换交给系统设置。

理解完模式的"能耐",接下来才是最影响视觉质量的部分:密度。


五、显示密度(DPI)与 px / vp / fp 换算

同样的 100 个逻辑单位,在 96 DPI 的笔记本屏和 220 DPI 的平板屏上,物理尺寸天差地别。HarmonyOS 的渲染体系用三把"尺子"来消弭这种差异:

  • densityPixels:逻辑密度系数(比如 2.0、3.0),是 vppx 之间的桥梁;
  • densityDPI:每英寸物理像素数(DPI),用于需要真实物理尺寸的场景;
  • scaledDensity:字体专用密度,额外叠加了用户对"字体放大"的设置,是 fppx 的桥梁。

换算关系非常朴素:

vp = px / densityPixels
fp = px / scaledDensity
px = vp * densityPixels

换句话说,只要你用 vp(ArkUI 默认单位)布局,系统已经帮你做了第一步适配。但当你要直接操作 PixelMap、Canvas、或者和原生分辨率对齐时,就必须手动换算。下面这个工具类把四种方向都补齐。

// DensityUtil.ets
import { display } from '@kit.ArkUI';

export class DensityUtil {
  private densityPixels: number;
  private scaledDensity: number;

  constructor(displayId?: number) {
    const d = displayId !== undefined
      ? display.getDisplayByIdSync(displayId)
      : display.getDefaultDisplaySync();
    this.densityPixels = d?.densityPixels ?? 1;
    this.scaledDensity = d?.scaledDensity ?? 1;
  }

  // 像素 → 虚拟像素
  px2vp(px: number): number {
    return px / this.densityPixels;
  }

  // 虚拟像素 → 像素
  vp2px(vp: number): number {
    return vp * this.densityPixels;
  }

  // 像素 → 字体像素
  px2fp(px: number): number {
    return px / this.scaledDensity;
  }

  // 字体像素 → 像素
  fp2px(fp: number): number {
    return fp * this.scaledDensity;
  }

  get dpi(): number {
    const d = display.getDefaultDisplaySync();
    return d.densityDPI;
  }
}

一个实用的细节:DensityUtil 允许传入 displayId,这样副屏(比如一块低 DPI 的投影仪)与主屏用不同的系数换算,搬过去的 Canvas 才不会糊。密度换算解决了"画多大",下一个问题是"字该多大"。


六、字体缩放策略与 Configuration 响应

用户在系统设置里把字体调大,应用里的字也必须跟着变大,否则就是"系统放大了、你却没动"的割裂感。HarmonyOS 通过 AbilityContextonConfigurationUpdate 回调把这类变化推给应用,关键字段是 Configuration.fontSizeScale(取值如 1.0、1.5、2.0)。

更妙的是,ArkUI 的 fontSize 默认就以 fp 为单位,所以"用 fp 设置字号"天然跟随系统缩放。但当你用代码动态计算字号、或者把字号存进数据库时,就必须主动读 fontSizeScale 并响应其变化。下面演示一个会随系统字体缩放实时刷新的组件。

// FontScalePage.ets
import { common } from '@kit.AbilityKit';
import { Configuration } from '@kit.AbilityKit';

@Entry
@Component
struct FontScalePage {
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  @State fontSizeScale: number = 1.0;
  @State dynamicText: string = '当前跟随系统字体缩放';

  aboutToAppear(): void {
    // 初始读取一次
    this.fontSizeScale = this.context.config?.fontSizeScale ?? 1.0;
    // 注册配置变化监听
    this.context.onConfigurationUpdate((config: Configuration) => {
      this.fontSizeScale = config.fontSizeScale;
      console.info(`字体缩放变为: ${config.fontSizeScale}`);
    });
  }

  build() {
    Column({ space: 16 }) {
      Text(this.dynamicText)
        // fp 单位,天然跟随系统;这里再乘 scale 仅作演示
        .fontSize(16 * this.fontSizeScale)
        .fontColor('#333333')

      Text(`fontSizeScale = ${this.fontSizeScale}`)
        .fontSize(14)
        .fontColor('#888888')
    }
    .width('100%')
    .padding(24)
  }
}

在这里插入图片描述

小结:fp 是字体适配的第一道防线,onConfigurationUpdate 是第二道——尤其当你要做"超大字体模式下的布局重排"时,后者不可或缺。密度与字体的问题都在"单屏内"解决后,真正的 PC 特色来了:跨屏搬窗口。


七、窗口在多显示器之间迁移

这是本文最贴近"PC 操作直觉"的一环。用户点了"把播放器搬到副屏",应用需要完成两件事:先把窗口绑定到目标 Display,再把窗口移动到该屏坐标系内的具体位置。

API 12+ 下对应的两个方法是 Window.setWindowDisplayAffinity(displayId)(设置窗口归属的屏幕)与 Window.moveWindowTo(x, y)(在归属屏内移动)。注意 moveWindowTo 的坐标是相对于当前归属屏左上角的逻辑像素。下面把整套迁移逻辑封装成一个函数。

// WindowMover.ets
import { window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';

export class WindowMover {
  private context: common.UIAbilityContext;

  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  // 把当前 Ability 的主窗口搬到指定 display 的 (x, y)
  async moveToDisplay(displayId: number, x: number, y: number): Promise<boolean> {
    try {
      const win: window.Window = await window.getLastWindow(this.context);
      // 第一步:改归属屏(affinity)
      await win.setWindowDisplayAffinity(displayId);
      // 第二步:在目标屏坐标系内定位
      await win.moveWindowTo(x, y);
      console.info(`窗口已迁移到 display ${displayId} @ (${x}, ${y})`);
      return true;
    } catch (e) {
      console.error(`跨屏迁移失败: ${(e as Error).message}`);
      return false;
    }
  }

  // 居中显示在某块屏
  async centerOnDisplay(displayId: number,
                        winW: number, winH: number): Promise<boolean> {
    const d = window.findWindow('') // 占位,下方用 display 获取尺寸
      ?? null;
    // 用 display 模块取目标屏尺寸更稳妥
    const target = (await import('@ohos.display')).display.getDisplayByIdSync(displayId);
    if (!target) {
      return false;
    }
    const x = Math.round((target.width - winW) / 2);
    const y = Math.round((target.height - winH) / 2);
    return this.moveToDisplay(displayId, x, y);
  }
}

需要点出两个工程陷阱。第一,setWindowDisplayAffinity 对窗口类型有限制,主要用于应用主窗与特定系统窗,普通 WindowType.TYPE_APP_MAIN 主窗可用,子窗(如弹窗)不一定支持,调用前最好用 getWindowProperties().type 确认。

第二个陷阱更隐蔽:moveWindowTo 的原点是"当前归属屏"。如果你先 moveWindowTosetWindowDisplayAffinity,坐标语义会错位。正确顺序永远是先换屏、再定位。这点我们特意用注释标注在代码里。

理解了迁移,还剩最后一个边界:当目标屏是一块 4K 智慧屏甚至 8K 电视时,布局该怎么约束?


八、超大屏 / 智慧屏布局约束

手机布局直接搬到 65 寸智慧屏上,结果就是"内容缩在中间一小团"。超大屏的关键不是"放大",而是重排:利用更宽的横向空间切换为多列网格、提升信息密度、同时保证触控/远场操作的最小命中区域。

ArkUI 的响应式布局以 vp 为基础,配合断点(breakpoint)思想最稳妥:以"当前屏宽度(vp)"划分 sm / md / lg / xl,不同断点套用不同 Grid 列数。下面这个组件读取自己所在 Display 的宽度换算成 vp,再决定列数。

// ResponsiveGrid.ets
import { display } from '@kit.ArkUI';

@Component
export struct ResponsiveGrid {
  @Prop screenWidthPx: number = 0;
  @Prop densityPixels: number = 1;
  @State columns: number = 2;

  aboutToAppear(): void {
    this.recompute();
  }

  private recompute(): void {
    const widthVp = this.screenWidthPx / this.densityPixels;
    // 断点:<600vp 单列,600~1200 两列,1200~2400 三列,>2400 四列
    if (widthVp < 600) {
      this.columns = 1;
    } else if (widthVp < 1200) {
      this.columns = 2;
    } else if (widthVp < 2400) {
      this.columns = 3;
    } else {
      this.columns = 4;
    }
  }

  build() {
    Grid() {
      ForEach([1, 2, 3, 4, 5, 6, 7, 8], (item: number) => {
        GridItem() {
          Column() {
            Text(`卡片 ${item}`)
              .fontSize(18)
              .fontColor('#222222')
          }
          .width('100%')
          .height(120)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#F2F3F5')
          .borderRadius(12)
        }
      })
    }
    .columnsTemplate(this.buildTemplate())
    .width('100%')
    .padding(16)
  }

  private buildTemplate(): string {
    return Array(this.columns).fill('1fr').join(' ');
  }
}

一个经验法则:智慧屏上不要无脑放大字号,而要放大"信息密度"。把单列改成多列、把列表改成看板,体验提升远比把字号翻倍明显。同时记得给可点击元素保留足够大的命中区域(建议 ≥ 48vp),远场遥控和触摸才好用。


九、完整实战:一个多屏桌面工具

把前面所有模块串起来,做一个能"列出所有屏幕、显示每块屏密度、一键把窗口搬到右屏居中"的迷你工具。下面给出核心页面,工程可直接集成。

// MultiScreenToolPage.ets
import { display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { DisplayUtil, ScreenSnapshot } from '../utils/DisplayUtil';
import { DisplayMonitor } from '../utils/DisplayMonitor';
import { WindowMover } from '../utils/WindowMover';

@Entry
@Component
struct MultiScreenToolPage {
  private context: common.UIAbilityContext =
    getContext(this) as common.UIAbilityContext;
  private monitor: DisplayMonitor = new DisplayMonitor();
  private mover: WindowMover = new WindowMover(this.context as common.UIAbilityContext);

  @State screens: ScreenSnapshot[] = [];
  @State log: string = '就绪';

  aboutToAppear(): void {
    this.refresh();
    // 热插拔实时刷新列表
    this.monitor.on('add', () => this.refresh());
    this.monitor.on('remove', () => this.refresh());
    this.monitor.on('change', () => this.refresh());
  }

  aboutToDisappear(): void {
    this.monitor.dispose();
  }

  private refresh(): void {
    this.screens = DisplayUtil.getAllSync();
  }

  // 把本窗口搬到指定屏并居中
  private async sendToDisplay(id: number): Promise<void> {
    const target = DisplayUtil.getByIdSync(id);
    if (!target) {
      this.log = `找不到屏幕 ${id}`;
      return;
    }
    const ok = await this.mover.centerOnDisplay(id, 800, 600);
    this.log = ok
      ? `已迁移到 ${target.name} (${target.width}x${target.height}, ${target.densityDPI}dpi)`
      : '迁移失败,请检查窗口类型与权限';
  }

  build() {
    Column({ space: 12 }) {
      Text('多屏桌面工具')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 16, bottom: 8 })

      List() {
        ForEach(this.screens, (s: ScreenSnapshot) => {
          ListItem() {
            Row({ space: 12 }) {
              Column({ space: 4 }) {
                Text(`${s.isPrimary ? '主屏' : '副屏'} · ${s.name}`)
                  .fontSize(18)
                  .fontColor('#222222')
                Text(`${s.width}x${s.height} · ${s.refreshRate}Hz · ${s.densityDPI}dpi`)
                  .fontSize(13)
                  .fontColor('#888888')
              }
              .layoutWeight(1)

              Button('搬到此处')
                .fontSize(14)
                .onClick(() => this.sendToDisplay(s.id))
            }
            .width('100%')
            .padding(16)
            .backgroundColor('#FFFFFF')
            .borderRadius(10)
          }
        })
      }
      .width('100%')
      .layoutWeight(1)

      Text(this.log)
        .fontSize(13)
        .fontColor('#666666')
        .padding(12)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#EEF0F2')
  }
}

这个页面把"感知(监听)— 理解(密度/分辨率)— 行动(跨屏迁移)"三段式闭环真正跑通了。把它和前几版的窗口管理、IPC、托盘通知组合,就是一套相当完整的 PC 应用底座。


十、小结与最佳实践

回看全文,我们围绕三件事展开:用 display.getAllDisplays()display.on 感知多屏世界,用 densityPixels / scaledDensityDensityUtil 把密度换算做对,用 setWindowDisplayAffinity + moveWindowTo 完成窗口跨屏迁移,最后用断点思想约束超大屏布局。

几条值得贴在工位上的经验:

  • 先换屏、再定位setWindowDisplayAffinity 必须在 moveWindowTo 之前调用,否则坐标语义错位。
  • 监听器成对注销display.on 一定要配 display.off,否则屏幕事件会回调到已销毁页面。
  • 密度换算随身带 displayId:副屏与主屏 DPI 可能不同,Canvas / PixelMap 操作要按目标屏系数换算。
  • 字体用 fp、布局用断点fp 跟随系统字号,responsive breakpoint 比无脑放大字号更适合超大屏。
  • 系统能力有边界setDisplayMode 等属系统权限,三方应用多只读,切换交给系统设置更稳妥。

多显示器不是炫技,而是 PC 形态下用户真实的工作方式。把这套感知—适配—迁移的能力做扎实,你的鸿蒙应用才算真正"上得了桌面"。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐