欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
摘要 :PC 与手机最大的硬件差异是"屏幕大、分辨率杂、有鼠标"。本文聚焦鸿蒙PC上两个高频适配点------高分屏 DPI 缩放检测与右键上下文菜单,给出真实运行代码,并复盘两个编译期就暴雷的坑(scale 命名冲突、getDefaultDisplaySync 抛异常)。所有代码已在鸿蒙PC模拟器跑通,工程已传 AtomGit,附全屏截图清单。
手机分辨率相对固定,PC 却是 1080P、2K、4K、带鱼屏一应俱全。同一套 UI 在 4K 屏上可能小到看不清,在触屏本上又可能大得挤。再加上鼠标右键、悬停这些手机没有的交互------DPI 与键鼠适配,是鸿蒙PC应用"看起来像PC软件"的分水岭。这篇把它讲透,并把两个我编译期就踩中的坑一并交待。
目录
- [一、DPI 缩放检测:先拿到屏幕真实数据](#一、DPI 缩放检测:先拿到屏幕真实数据)
- [二、坑 2:函数名叫
scale会编译失败](#二、坑 2:函数名叫 scale 会编译失败) - [三、右键菜单:PC 专属交互](#三、右键菜单:PC 专属交互)
- [3.1 完整可运行代码:右键菜单从捕获到生效](#3.1 完整可运行代码:右键菜单从捕获到生效)
- 四、高分屏自适应的三条原则
- [4.1 完整代码:用 densityPixels 计算字号与间距](#4.1 完整代码:用 densityPixels 计算字号与间距)
- [五、DevEco Studio 6.0(Dev Eco 26)开发环境搭建](#五、DevEco Studio 6.0(Dev Eco 26)开发环境搭建)
- [5.1 环境清单](#5.1 环境清单)
- [5.2 关键配置步骤](#5.2 关键配置步骤)
- [5.3 工程架构与适配链路](#5.3 工程架构与适配链路)
- 六、在鸿蒙PC上验证
- 七、沉淀与共建
一、DPI 缩放检测:先拿到屏幕真实数据
OpenHarmony 用 @ohos.display 暴露屏幕信息。关键字段是 densityPixels(每逻辑像素对应物理像素,如 1.5/2.0/3.0)、densityDPI(如 240/480),以及 width/height(物理分辨率)。
ts
aboutToAppear(): void {
try {
const d = display.getDefaultDisplaySync();
this.dpi = d.densityPixels;
this.densityDpi = d.densityDPI;
this.resolution = `${d.width} x ${d.height}`;
} catch (err) {
const e = err as BusinessError;
this.statusMsg = `DPI 读取失败 code=${e.code}`;
}
}
坑 1 :
getDefaultDisplaySync()是同步 API,但在窗口/display 服务未就绪时会抛BusinessError。ArkTS 编译器还会对"可能抛异常的函数"报 WARN,必须用try-catch包住,否则编译不过。
二、坑 2:函数名叫 scale 会编译失败
我最初写了个缩放辅助函数 private scale(size) 用来算字号。一编译满屏红:Argument of type 'number' is not assignable to ...。排查半天才明白:CustomComponent 基类已经有一个 scale 属性 ,ArkUI 的 .scale({ x: 1.5 }) 修饰器用的就是它。我自己写的 scale 和基类冲突,类型直接不兼容。改名 scaleFont 后立刻通过。这个坑很隐蔽,记下来省得别人再查。
三、右键菜单:PC 专属交互
手机没有右键,PC 上右键弹菜单是基本操作。ArkUI 用 onMouse 捕获鼠标事件,button===2 && action===1 即"右键按下":
ts
.onMouse((e: MouseEvent) => {
if (e.button === 2 && e.action === 1) { // 右键按下
this.menuX = e.x;
this.menuY = e.y;
this.showMenu = true;
}
})
菜单用 Column + position({ x, y }) 自绘,锚定到鼠标点击坐标。我还加了 onHover 悬停高亮,让"这是PC鼠标交互"一目了然。菜单项要真正生效才有意义------我的"新建卡片"会往列表追加,"切换底色"会改背景色,"关闭菜单"收起,不是摆设。
3.1 完整可运行代码:右键菜单从捕获到生效
下面这段是 Index.ets 里右键菜单的完整实现,包含状态定义、onMouse 捕获、Column + position 自绘菜单,以及三个菜单项的真实处理逻辑:
ts
@Entry
@Component
struct Index {
// ===== 菜单状态 =====
@State menuX: number = 0; // 菜单左上角 x 坐标(相对组件)
@State menuY: number = 0; // 菜单左上角 y 坐标
@State showMenu: boolean = false; // 是否显示菜单
@State hoverIndex: number = -1; // 当前悬停的菜单项下标,-1 表示无
// ===== 业务状态 =====
@State cardCount: number = 0; // 卡片数量,"新建卡片"会 +1
@State bgColor: string = '#FFFFFF'; // 页面底色,"切换底色"会轮换
// 底色轮换表
private readonly BG_LIST: string[] = ['#FFFFFF', '#F2F3F5', '#E8F3FF', '#FFF4E6'];
/**
* 右键捕获:button === 2 表示右键,action === 1 表示按下
* 记录坐标后把 showMenu 置 true,菜单即出现在鼠标位置
*/
private onRightClick(e: MouseEvent): void {
if (e.button === 2 && e.action === 1) {
this.menuX = e.x;
this.menuY = e.y;
this.showMenu = true;
}
}
/** 菜单项 1:新建卡片 ------ 真正往列表里追加数据 */
private handleNewCard(): void {
this.cardCount += 1;
this.showMenu = false; // 操作完收起菜单
}
/** 菜单项 2:切换底色 ------ 在预设色板里循环 */
private handleSwitchBg(): void {
const idx = this.BG_LIST.indexOf(this.bgColor);
this.bgColor = this.BG_LIST[(idx + 1) % this.BG_LIST.length];
this.showMenu = false;
}
/** 菜单项 3:关闭菜单 ------ 只收起,不改业务数据 */
private handleCloseMenu(): void {
this.showMenu = false;
}
/** 自绘菜单:Column + position 锚定到鼠标坐标 */
@Builder
ContextMenu() {
Column() {
this.MenuItem('新建卡片', 0, () => this.handleNewCard())
this.MenuItem('切换底色', 1, () => this.handleSwitchBg())
this.MenuItem('关闭菜单', 2, () => this.handleCloseMenu())
}
.width(140)
.padding(4)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.shadow({ radius: 12, color: '#33000000', offsetY: 4 })
// 关键:用 position 把菜单钉在右键点击处
.position({ x: this.menuX, y: this.menuY })
}
/** 单个菜单项:悬停高亮 + 点击回调 */
@Builder
MenuItem(label: string, index: number, action: () => void) {
Text(label)
.width('100%')
.height(36)
.padding({ left: 12, right: 12 })
.fontSize(14)
.textAlign(TextAlign.Start)
.borderRadius(6)
// 悬停高亮:让"这是PC鼠标交互"一目了然
.backgroundColor(this.hoverIndex === index ? '#E8F3FF' : Color.Transparent)
.onHover((isHover: boolean) => {
this.hoverIndex = isHover ? index : -1;
})
.onClick(() => action())
}
build() {
Stack({ alignContent: Alignment.TopStart }) {
Column({ space: 12 }) {
Text(`当前卡片数:${this.cardCount}`)
.fontSize(16)
// 演示区:在这里点右键
Text('在此区域点击鼠标右键')
.width('100%')
.height(200)
.fontSize(16)
.textAlign(TextAlign.Center)
.backgroundColor('#F7F8FA')
.borderRadius(8)
.onMouse((e: MouseEvent) => this.onRightClick(e))
}
.width('100%')
.padding(20)
.alignItems(HorizontalAlign.Start)
// 菜单浮层:showMenu 为 true 时才渲染
if (this.showMenu) {
this.ContextMenu()
}
}
.width('100%')
.height('100%')
.backgroundColor(this.bgColor) // 底色由"切换底色"控制
// 点击空白处收起菜单
.onClick(() => {
if (this.showMenu) {
this.showMenu = false;
}
})
}
}
几个容易忽略的点:
position的坐标系 是父容器(这里是Stack)的左上角,e.x / e.y也是相对当前组件的坐标,两者一致才能"菜单出现在鼠标处";- 菜单必须放在
Stack里 ,否则position会参与普通布局流,把下面的内容挤走; onHover的isHover是布尔值 ,离开时要记得把hoverIndex复位成-1,否则高亮会"粘住";- 每个菜单项都要真正改数据 (
cardCount、bgColor),只弹不做的菜单在评审时一眼就被看穿。
四、高分屏自适应的三条原则
| 原则 | 做法 | 反例 |
|---|---|---|
| 字号随 DPI | size * densityPixels |
写死 16px |
| 布局用百分比/flex | width('80%')、Flex |
写死 800px 宽 |
间距用 vp 而非 px |
ArkUI 默认 vp 已含缩放 |
混用 px 导致错位 |
补充一点:ArkUI 长度单位默认就是 vp(虚拟像素),本身已做 DPI 归一化。多数情况你不用手动乘 dpi ,只有拿到物理像素数据时才换算。本文显式乘 densityPixels 是为了演示"感知缩放",真实业务更推荐直接 vp + 弹性布局。
4.1 完整代码:用 densityPixels 计算字号与间距
下面这段是可直接放进 Index.ets 的完整实现,包含 scaleFont 函数、间距换算函数,以及在 Text 组件上的调用方式:
ts
import display from '@ohos.display';
import { BusinessError } from '@ohos.base';
@Entry
@Component
struct Index {
// 屏幕密度:1 个 vp 对应的物理像素数,常见 1.5 / 2.0 / 3.0
@State densityPixels: number = 1.0;
// 基准字号(设计稿按 1x 标注,单位 vp)
private readonly BASE_FONT: number = 16;
// 基准间距(设计稿按 1x 标注,单位 vp)
private readonly BASE_GAP: number = 12;
aboutToAppear(): void {
try {
const d = display.getDefaultDisplaySync();
this.densityPixels = d.densityPixels;
} catch (err) {
const e = err as BusinessError;
console.error(`DPI 读取失败 code=${e.code}`);
this.densityPixels = 1.0; // 兜底,避免后续除零或 NaN
}
}
/**
* 字号缩放:把设计稿的 vp 字号按屏幕密度放大
* 注意函数名不能叫 scale ------ 会与 CustomComponent 基类的 scale 属性冲突
*/
private scaleFont(size: number): number {
return size * this.densityPixels;
}
/**
* 间距缩放:与字号同一套换算逻辑,保证视觉比例一致
*/
private scaleGap(gap: number): number {
return gap * this.densityPixels;
}
/**
* px -> vp 换算:vp = px / densityPixels
* 拿到物理像素(如截图尺寸、图片原始宽高)时用它转成 ArkUI 可用的 vp
*/
private px2vp(px: number): number {
return px / this.densityPixels;
}
/**
* vp -> px 换算:px = vp * densityPixels
* 需要把布局尺寸回传给只认物理像素的接口时用它
*/
private vp2px(vp: number): number {
return vp * this.densityPixels;
}
build() {
Column({ space: this.scaleGap(this.BASE_GAP) }) {
// 标题:基准 24vp,随 DPI 放大
Text('高分屏适配演示')
.fontSize(this.scaleFont(24))
.fontWeight(FontWeight.Bold)
// 正文:基准 16vp,随 DPI 放大
Text(`当前 densityPixels = ${this.densityPixels}`)
.fontSize(this.scaleFont(this.BASE_FONT))
// 说明:展示 vp 与 px 的换算结果
Text(`16vp 在本屏约等于 ${this.vp2px(16).toFixed(1)}px`)
.fontSize(this.scaleFont(14))
.fontColor('#666666')
// 卡片:内边距同样按密度缩放
Column() {
Text('卡片内容')
.fontSize(this.scaleFont(this.BASE_FONT))
}
.padding(this.scaleGap(16))
.width('80%')
.backgroundColor('#F2F3F5')
.borderRadius(this.scaleGap(8))
}
.width('100%')
.padding(this.scaleGap(20))
.alignItems(HorizontalAlign.Start)
}
}
关于 vp 与 px 的换算关系,记住一个公式就够了:
text
px = vp × densityPixels
vp = px ÷ densityPixels
vp(virtual pixel)是 ArkUI 的默认长度单位,系统已按屏幕密度做过归一化,所以写fontSize(16)在 1.5 和 3.0 的屏上视觉大小是一致的;px是物理像素,只在拿到硬件原始数据(截图尺寸、图片像素宽高、display返回的width/height)时才出现;- 上面代码里显式乘
densityPixels是为了演示"感知缩放"的效果,方便你在模拟器切换密度时肉眼看到差异。真实业务中,若设计稿本身就是 vp 标注,直接写 vp 数值即可,不必再乘。
五、DevEco Studio 6.0(Dev Eco 26)开发环境搭建
代码能跑通的前提是环境对。鸿蒙PC 应用必须用支持 PC 设备类型的 IDE 与 SDK,下面是我实际使用的组合,照着配基本不会踩坑。
5.1 环境清单
| 组件 | 版本 / 说明 |
|---|---|
| DevEco Studio | 6.0 Release(社区常称 Dev Eco 26),需选支持 PC 的版本 |
| HarmonyOS SDK | API 12 及以上,勾选 PC 设备类型 |
| Node.js | IDE 内置,无需单独安装 |
| 构建工具 | hvigor(IDE 自带,随工程 hvigor-config.json5 锁定) |
| 模拟器 | 鸿蒙PC 模拟器镜像,分辨率建议 2K 以上以验证 DPI |
开发运行效果如下:

5.2 关键配置步骤
- 安装 DevEco Studio 6.0,首次启动时在 Settings → OpenHarmony SDK 中下载 SDK,务必勾选
PC平台组件; - 新建工程时模板选 Empty Ability ,
Device Type勾选phone与pc(或2in1),否则右键菜单等 PC 能力无法在真机/模拟器上生效; - 在
module.json5中确认设备类型声明:
json
{
"module": {
"name": "entry",
"deviceTypes": ["phone", "2in1", "tablet"]
}
}
- 打开 Device Manager 创建鸿蒙PC 模拟器,启动后
Run 'entry'即可部署; - 若编译报
hvigor版本不匹配,按提示执行Sync Now,不要手动改hvigor-config.json5里的版本号。
提示 :
2in1是鸿蒙对 PC/二合一设备的设备类型标识,写pc在部分 SDK 版本上会校验失败,以 IDE 模板生成的值为准。
5.3 工程架构与适配链路
下图是本 demo 的整体结构,以及 DPI 检测、鼠标事件、菜单渲染三条链路如何汇入同一个页面:
#mermaid-svg-SrntWZo2an49l1Zq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SrntWZo2an49l1Zq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SrntWZo2an49l1Zq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SrntWZo2an49l1Zq .error-icon{fill:#552222;}#mermaid-svg-SrntWZo2an49l1Zq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SrntWZo2an49l1Zq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SrntWZo2an49l1Zq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SrntWZo2an49l1Zq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SrntWZo2an49l1Zq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SrntWZo2an49l1Zq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SrntWZo2an49l1Zq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SrntWZo2an49l1Zq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SrntWZo2an49l1Zq .marker.cross{stroke:#333333;}#mermaid-svg-SrntWZo2an49l1Zq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SrntWZo2an49l1Zq p{margin:0;}#mermaid-svg-SrntWZo2an49l1Zq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SrntWZo2an49l1Zq .cluster-label text{fill:#333;}#mermaid-svg-SrntWZo2an49l1Zq .cluster-label span{color:#333;}#mermaid-svg-SrntWZo2an49l1Zq .cluster-label span p{background-color:transparent;}#mermaid-svg-SrntWZo2an49l1Zq .label text,#mermaid-svg-SrntWZo2an49l1Zq span{fill:#333;color:#333;}#mermaid-svg-SrntWZo2an49l1Zq .node rect,#mermaid-svg-SrntWZo2an49l1Zq .node circle,#mermaid-svg-SrntWZo2an49l1Zq .node ellipse,#mermaid-svg-SrntWZo2an49l1Zq .node polygon,#mermaid-svg-SrntWZo2an49l1Zq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SrntWZo2an49l1Zq .rough-node .label text,#mermaid-svg-SrntWZo2an49l1Zq .node .label text,#mermaid-svg-SrntWZo2an49l1Zq .image-shape .label,#mermaid-svg-SrntWZo2an49l1Zq .icon-shape .label{text-anchor:middle;}#mermaid-svg-SrntWZo2an49l1Zq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SrntWZo2an49l1Zq .rough-node .label,#mermaid-svg-SrntWZo2an49l1Zq .node .label,#mermaid-svg-SrntWZo2an49l1Zq .image-shape .label,#mermaid-svg-SrntWZo2an49l1Zq .icon-shape .label{text-align:center;}#mermaid-svg-SrntWZo2an49l1Zq .node.clickable{cursor:pointer;}#mermaid-svg-SrntWZo2an49l1Zq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SrntWZo2an49l1Zq .arrowheadPath{fill:#333333;}#mermaid-svg-SrntWZo2an49l1Zq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SrntWZo2an49l1Zq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SrntWZo2an49l1Zq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SrntWZo2an49l1Zq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SrntWZo2an49l1Zq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SrntWZo2an49l1Zq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SrntWZo2an49l1Zq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SrntWZo2an49l1Zq .cluster text{fill:#333;}#mermaid-svg-SrntWZo2an49l1Zq .cluster span{color:#333;}#mermaid-svg-SrntWZo2an49l1Zq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SrntWZo2an49l1Zq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SrntWZo2an49l1Zq rect.text{fill:none;stroke-width:0;}#mermaid-svg-SrntWZo2an49l1Zq .icon-shape,#mermaid-svg-SrntWZo2an49l1Zq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SrntWZo2an49l1Zq .icon-shape p,#mermaid-svg-SrntWZo2an49l1Zq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SrntWZo2an49l1Zq .icon-shape .label rect,#mermaid-svg-SrntWZo2an49l1Zq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SrntWZo2an49l1Zq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SrntWZo2an49l1Zq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SrntWZo2an49l1Zq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 系统能力层
应用层 Index.ets
DevEco Studio 6.0 开发环境
工程 entry 模块
module.json5 声明 2in1
鸿蒙PC 模拟器
aboutToAppear 读取 DPI
状态变量 dpi / resolution
onMouse 捕获右键
menuX / menuY / showMenu
onHover 悬停高亮
hoverIndex
@ohos.display 屏幕信息
MouseEvent 鼠标事件
字号 = size * densityPixels
Column + position 自绘菜单
高分屏自适应 UI
从图里能看清一件事:DPI 与鼠标事件是两条独立输入,最终都收敛到同一个渲染层。所以适配工作可以拆开做------先保证 DPI 数值读对、字号算对,再补右键与悬停交互,互不阻塞。
六、在鸿蒙PC上验证
完成编译:
运行效果如下:

点击右键即可进行识别:

七、沉淀与共建
DPI 与键鼠适配是 PC 应用的基本功,却被很多从手机转过来的开发者忽视。本文 demo 含 DPI 检测、悬停高亮、右键菜单三段可运行代码,README 写了上面两个编译坑。欢迎补充你遇到过的奇葩分辨率案例,一起把鸿蒙PC的适配经验库填厚。
小结:PC 不是"大号手机"。高分屏要会随 DPI 缩放,交互要接住鼠标右键与悬停。把这两件事做对,你的应用才会被用户一眼认出"这是个正经PC软件"。这恰恰是开源鸿蒙PC最缺、也最值得你贡献经验的方向。