鸿蒙 PC 应用开发实战:多显示器、显示密度与跨屏适配
一、为什么 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();
}
}
使用方式非常直接:在 EntryAbility 的 onWindowStageCreate 里注册,在 onDestroy 里 dispose()。这里有一个工程经验——监听器一定要成对注册与注销,否则屏幕事件会持续回调到已经销毁的页面,轻则打印空指针,重则引发崩溃。
// 在页面或 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。系统当前用的是其中某一个 DisplayMode。Display 对象上的 refreshRate 和 width/height 只反映"当前模式",而 display.getSupportedModes(id) 能列出"所有可选模式"。
DisplayMode 的结构很简单:id、width、height、refreshRate。下面这段代码演示如何枚举某块屏的全部模式,并挑出刷新率最高的那一个,供用户做"性能模式"切换。
// 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),是
vp与px之间的桥梁; - densityDPI:每英寸物理像素数(DPI),用于需要真实物理尺寸的场景;
- scaledDensity:字体专用密度,额外叠加了用户对"字体放大"的设置,是
fp与px的桥梁。
换算关系非常朴素:
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 通过 AbilityContext 的 onConfigurationUpdate 回调把这类变化推给应用,关键字段是 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 的原点是"当前归属屏"。如果你先 moveWindowTo 再 setWindowDisplayAffinity,坐标语义会错位。正确顺序永远是先换屏、再定位。这点我们特意用注释标注在代码里。
理解了迁移,还剩最后一个边界:当目标屏是一块 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 / scaledDensity 与 DensityUtil 把密度换算做对,用 setWindowDisplayAffinity + moveWindowTo 完成窗口跨屏迁移,最后用断点思想约束超大屏布局。
几条值得贴在工位上的经验:
- 先换屏、再定位:
setWindowDisplayAffinity必须在moveWindowTo之前调用,否则坐标语义错位。 - 监听器成对注销:
display.on一定要配display.off,否则屏幕事件会回调到已销毁页面。 - 密度换算随身带 displayId:副屏与主屏 DPI 可能不同,Canvas / PixelMap 操作要按目标屏系数换算。
- 字体用 fp、布局用断点:
fp跟随系统字号,responsive breakpoint比无脑放大字号更适合超大屏。 - 系统能力有边界:
setDisplayMode等属系统权限,三方应用多只读,切换交给系统设置更稳妥。
多显示器不是炫技,而是 PC 形态下用户真实的工作方式。把这套感知—适配—迁移的能力做扎实,你的鸿蒙应用才算真正"上得了桌面"。
更多推荐



所有评论(0)