鸿蒙PC桌面应用从0到1:做一个能天天用的可拖拽便签
欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
摘要:我把手边最常用的"桌面便签"搬到了开源鸿蒙PC上。这篇不是单纯讲 API,而是复盘我从"窗口拖拽都做不对"到做出一个能每天用的小工具的全过程:窗口移动的坐标坑、preferences 本地持久化、最小化与置顶。所有代码已在 DevEco Studio + 鸿蒙PC模拟器跑通,工程已传 AtomGit,文末附完整全屏截图清单。
我桌面上常年贴着一张便签,记待办、记灵感。换到鸿蒙PC后第一件事就是想把它复刻出来。本以为很简单——不就是个能拖动的文本框吗?结果真动手才发现,手机上"页面铺满全屏"的思维惯性,在PC上根本行不通。这篇把我踩的坑和最终能用的代码都交代清楚。
本文目录
- 一、先想清楚:PC 应用和手机应用差在哪
- 二、窗口拖拽:一个让我调试到半夜的坐标坑
- 三、让便签"记得住":preferences 持久化
- 四、像桌面工具一样:最小化与置顶
- 五、常见报错与排查
- 六、完整页面与工程结构
- 七、在鸿蒙PC模拟器上验证(截图怎么做)
- 八、还能怎么扩展
一、先想清楚:PC 应用和手机应用差在哪
手机 ArkUI 页面默认铺满全屏,你基本不用管"窗口"这个概念。但到了鸿蒙PC,应用是一个可以拖动、最小化、置顶、缩放的独立窗口。所以一个便签要解决四件事:
- 标题栏能拖着窗口满屏跑;
- 文本能编辑并关掉再打开还在;
- 能最小化、能置顶,像个正经桌面工具;
- 代码干净,模拟器一键跑通。
第 1 点靠 @ohos.window 的 moveWindowTo,第 2 点靠 preferences 用户首选项,第 3 点靠 minimize() 和 setWindowKeepAbove()。下面逐个讲。
动手前先把开发环境对齐,避免因为版本差异出现"我这边能跑、你那边报错"的情况。本文使用的环境如下:
| 项目 | 版本 / 说明 |
|---|---|
| 开发工具 | DevEco Studio 5.0 及以上(建议升级到最新稳定版) |
| SDK | HarmonyOS SDK(API 12 及以上,含 PC 设备能力) |
| 运行设备 | 鸿蒙PC模拟器(或本地 PC 镜像) |
| 语言 / 框架 | ArkTS + ArkUI 声明式开发 |
| 关键 Kit | @ohos.window(窗口)、@kit.ArkData(preferences) |
本项目开发界面如下:

环境就绪后,新建一个 Empty Ability 工程,把后面的代码放进 pages/StickyNote.ets 即可。
整体上,一个便签应用在鸿蒙PC上的运行链路可以拆成三层:窗口层负责位置与状态,数据层负责内容落盘,UI 层负责交互。三者关系如下:
理解这张图,后面每个坑都能对上号:拖拽问题出在窗口层,内容丢失问题出在数据层。
二、窗口拖拽:一个让我调试到半夜的坐标坑
最初我直接把鼠标事件的 event.x / event.y 喂给 moveWindowTo,一点标题栏,窗口"啪"地跳到屏幕角落。原因是:event.x/y 是相对于当前组件的局部坐标,而 moveWindowTo 要的是屏幕绝对坐标。
正确做法是维护 posX / posY 表示窗口当前的绝对位置,每次移动只累加"位移增量":
private dragging: boolean = false;
private lastX: number = 0;
private lastY: number = 0;
.onMouse((event: MouseEvent) => {
if (event.button === 1 && event.action === 1) { // 左键按下:记录起点
this.dragging = true;
this.lastX = event.x;
this.lastY = event.y;
} else if (event.action === 3 && this.dragging) { // 移动中:累加增量
this.posX += event.x - this.lastX;
this.posY += event.y - this.lastY;
this.lastX = event.x;
this.lastY = event.y;
this.moveWindowTo(this.posX, this.posY); // 用绝对坐标移动
} else if (event.action === 2) { // 释放
this.dragging = false;
}
})
另一个坑是 getLastWindow 的时机。它必须等 Ability 上下文就绪后调用,放在用户交互触发的 onMouse 回调里最稳,别在 aboutToAppear 里过早拿窗口。鼠标按键枚举也要记牢:button 1=左键、2=右键;action 1=按下、2=抬起、3=移动。
把坐标关系画出来会更清楚。event.x/y 是相对标题栏组件的局部坐标,而 moveWindowTo 接收的是屏幕绝对坐标,两者之间差了一个窗口左上角的位置:
关键点在于每帧都要更新 lastX / lastY。如果只在按下时记录一次,移动过程中增量会不断累积成"总位移",窗口就会越拖越快、直接飞出屏幕——这也是我最初调试到半夜的原因之一。
另外补充一个边界处理:拖动时建议对 posX / posY 做一次范围约束,避免窗口被拖到屏幕外找不回来:
private clampPosition(x: number, y: number): [number, number] {
const screenW = display.getDefaultDisplaySync().width;
const screenH = display.getDefaultDisplaySync().height;
// 至少保留 80px 在屏幕内,防止窗口彻底拖丢
const minVisible = 80;
const cx = Math.min(Math.max(x, -(this.windowWidth - minVisible)), screenW - minVisible);
const cy = Math.min(Math.max(y, 0), screenH - minVisible);
return [cx, cy];
}
调用时把 moveWindowTo(this.posX, this.posY) 换成先 clampPosition 再移动,体验会稳很多。
三、让便签"记得住":preferences 持久化
光能拖不算完,关掉再打开内容没了就没法日常用。鸿蒙PC 上轻量键值存储用 preferences(来自 @kit.ArkData),比自己写文件省事:
import { preferences } from '@kit.ArkData';
private async loadContent(): Promise<void> {
try {
const pref = await preferences.getPreferences(getContext(this), 'StickyNoteStore');
this.content = pref.get('note_content', '在这里写下今天的待办...') as string;
} catch (e) {
console.error(`[StickyNote] 读取本地便签失败: ${(e as BusinessError).message}`);
}
}
private async saveContent(): Promise<void> {
try {
const pref = await preferences.getPreferences(getContext(this), 'StickyNoteStore');
await pref.put('note_content', this.content);
await pref.flush(); // 关键:flush 才真正落盘
} catch (e) {
console.error(`[StickyNote] 保存本地便签失败: ${(e as BusinessError).message}`);
}
}
每次 TextArea 变更就调 saveContent(),进程被杀再启动,aboutToAppear 里 loadContent() 读回来,内容原样还原。
持久化的完整生命周期可以这样理解——重点是 flush() 这一步,很多人只 put 不 flush,进程一杀内容就没了:
还有一个容易忽略的细节:preferences 实例建议复用而不是每次新建。频繁 getPreferences 会有额外开销,可以在 aboutToAppear 里拿一次存成成员变量:
private prefStore: preferences.Preferences | null = null;
async aboutToAppear(): Promise<void> {
this.prefStore = await preferences.getPreferences(getContext(this), 'StickyNoteStore');
await this.loadContent();
}
之后 saveContent() 直接用 this.prefStore,既省开销也避免并发读写同一文件时的时序问题。
四、像桌面工具一样:最小化与置顶
标题栏右边我放了两个按钮:置顶开关、最小化。window.Window 都给了现成方法:
private async minimizeWindow(): Promise<void> {
const win = await window.getLastWindow(getContext(this));
await win.minimize(); // 收进任务栏
}
private async toggleKeepAbove(): Promise<void> {
const win = await window.getLastWindow(getContext(this));
this.keptAbove = !this.keptAbove;
await win.setWindowKeepAbove(this.keptAbove); // 置顶/取消置顶
}
这两个 API 在鸿蒙PC上和 Windows/macOS 的行为一致,配合拖拽,便签就有了"桌面工具"的体感。
窗口状态之间的切换关系如下,置顶和最小化是两条独立的状态线,互不干扰:
需要注意 setWindowKeepAbove 是幂等设置而非切换,所以代码里要自己维护 keptAbove 布尔值再传参,不能无脑取反调用。另外这两个方法都返回 Promise,建议加 try/catch 兜底,避免窗口已销毁时抛异常打断交互:
private async toggleKeepAbove(): Promise<void> {
try {
const win = await window.getLastWindow(getContext(this));
this.keptAbove = !this.keptAbove;
await win.setWindowKeepAbove(this.keptAbove);
} catch (e) {
console.error(`[StickyNote] 置顶切换失败: ${(e as BusinessError).message}`);
}
}
五、常见报错与排查
上面四块拼起来能跑,但实际动手时几乎每个人都会在这几个地方卡一下。我把踩过的坑整理成一张排查表,先看现象对号入座,再看代码级说明:
| 问题 | 现象 | 常见原因 | 解决方式 | 代码级说明 |
|---|---|---|---|---|
moveWindowTo 无效 | 拖标题栏窗口不动,或一点就跳到屏幕角落 | 把 event.x/y(组件局部坐标)直接当屏幕绝对坐标传入;或窗口对象还没拿到就调用 | 维护 posX/posY 累加位移增量,并确保在交互回调里拿到窗口后再移动 | this.posX += event.x - this.lastX; await win.moveWindowTo(this.posX, this.posY); |
getLastWindow 返回空 | 抛异常或 win 为 undefined,后续 minimize() 直接报错 | 在 aboutToAppear 等 UI 尚未挂载的时机过早调用,Ability 上下文未就绪 | 改到用户交互触发的回调(如 onClick、onMouse)里调用,并加 try/catch | const win = await window.getLastWindow(getContext(this)); if (!win) return; |
preferences 读取为空 | 重启应用后 TextArea 回到默认占位文案 | 只 put 没 flush,数据还在内存;或读取时 store 名/键名与写入不一致 | 写入后必须 await pref.flush(),并统一 store 名与 key 常量 | await pref.put('note_content', this.content); await pref.flush(); |
setWindowKeepAbove 不生效 | 点了置顶按钮,窗口仍被其他窗口盖住 | 把幂等设置当成切换,无脑取反;或传了非布尔值 / 窗口已销毁 | 自己维护 keptAbove 布尔状态再传参,并捕获异常 | this.keptAbove = !this.keptAbove; await win.setWindowKeepAbove(this.keptAbove); |
几个通用建议:
- 先确认窗口对象非空。
getLastWindow是异步的,拿到win后先判空再调方法,能省掉一大半"莫名其妙不生效"的问题。 - 所有窗口 API 都包
try/catch。窗口被销毁、应用退到后台时调用会抛异常,不捕获会直接打断后续交互逻辑。 - 持久化统一常量。把 store 名和 key 抽成常量(如
STORE_NAME、KEY_CONTENT),避免读写两处手写字符串拼错导致"读不到"。
六、完整页面与工程结构
前面几节都是拆开讲的片段,真正跑起来需要把它们拼进同一个 StickyNote.ets。下面给出完整页面骨架,包含状态变量、窗口初始化、拖拽、持久化与标题栏按钮,复制进工程即可编译运行:
import { window, display } from '@kit.ArkUI';
import { preferences } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
const STORE_NAME: string = 'StickyNoteStore';
const KEY_CONTENT: string = 'note_content';
@Entry
@Component
struct StickyNote {
@State content: string = '在这里写下今天的待办...';
@State keptAbove: boolean = false;
private prefStore: preferences.Preferences | null = null;
private win: window.Window | null = null;
private posX: number = 120;
private posY: number = 120;
private windowWidth: number = 360;
private dragging: boolean = false;
private lastX: number = 0;
private lastY: number = 0;
async aboutToAppear(): Promise<void> {
this.prefStore = await preferences.getPreferences(getContext(this), STORE_NAME);
await this.loadContent();
}
private async loadContent(): Promise<void> {
try {
this.content = this.prefStore!.getSync(KEY_CONTENT, this.content) as string;
} catch (e) {
console.error(`[StickyNote] 读取失败: ${(e as BusinessError).message}`);
}
}
private async saveContent(): Promise<void> {
try {
await this.prefStore!.put(KEY_CONTENT, this.content);
await this.prefStore!.flush(); // 关键:flush 才真正落盘
} catch (e) {
console.error(`[StickyNote] 保存失败: ${(e as BusinessError).message}`);
}
}
private async getWin(): Promise<window.Window | null> {
if (this.win) {
return this.win;
}
try {
this.win = await window.getLastWindow(getContext(this));
return this.win;
} catch (e) {
console.error(`[StickyNote] 获取窗口失败: ${(e as BusinessError).message}`);
return null;
}
}
private async moveWindowTo(x: number, y: number): Promise<void> {
const win = await this.getWin();
if (!win) {
return;
}
const [cx, cy] = this.clampPosition(x, y);
this.posX = cx;
this.posY = cy;
await win.moveWindowTo(cx, cy);
}
private clampPosition(x: number, y: number): [number, number] {
const screenW = display.getDefaultDisplaySync().width;
const screenH = display.getDefaultDisplaySync().height;
const minVisible = 80;
const cx = Math.min(Math.max(x, -(this.windowWidth - minVisible)), screenW - minVisible);
const cy = Math.min(Math.max(y, 0), screenH - minVisible);
return [cx, cy];
}
private async minimizeWindow(): Promise<void> {
const win = await this.getWin();
if (!win) {
return;
}
await win.minimize();
}
private async toggleKeepAbove(): Promise<void> {
const win = await this.getWin();
if (!win) {
return;
}
this.keptAbove = !this.keptAbove;
await win.setWindowKeepAbove(this.keptAbove);
}
build() {
Column() {
// 标题栏:拖拽区 + 置顶/最小化按钮
Row() {
Text('桌面便签')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
Button(this.keptAbove ? '取消置顶' : '置顶')
.fontSize(12)
.height(28)
.onClick(() => { this.toggleKeepAbove(); })
Button('最小化')
.fontSize(12)
.height(28)
.margin({ left: 8 })
.onClick(() => { this.minimizeWindow(); })
}
.width('100%')
.height(44)
.padding({ left: 12, right: 12 })
.backgroundColor('#F1F3F5')
.onMouse((event: MouseEvent) => {
if (event.button === 1 && event.action === 1) {
this.dragging = true;
this.lastX = event.x;
this.lastY = event.y;
} else if (event.action === 3 && this.dragging) {
this.posX += event.x - this.lastX;
this.posY += event.y - this.lastY;
this.lastX = event.x;
this.lastY = event.y;
this.moveWindowTo(this.posX, this.posY);
} else if (event.action === 2) {
this.dragging = false;
}
})
// 正文编辑区
TextArea({ text: this.content })
.width('100%')
.layoutWeight(1)
.padding(12)
.backgroundColor('#FFFDE7')
.onChange((value: string) => {
this.content = value;
this.saveContent();
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFFDE7')
}
}
工程目录结构如下:
entry/src/main/ets/
├── entryability/EntryAbility.ts
└── pages/
├── Home.ets # 演示入口导航
└── StickyNote.ets # 本文主角:可拖拽+持久化+最小化/置顶
几点说明:
- 窗口对象只取一次。
getWin()内部做了缓存,避免每次拖拽都await getLastWindow,拖动时更跟手。 moveWindowTo统一走clampPosition。所有移动都经过边界约束,窗口不会被拖出屏幕。TextArea的onChange直接落盘。输入即保存,配合flush()保证进程被杀也不丢内容。- 标题栏用
Row承载拖拽事件。按钮点击与拖拽互不干扰,layoutWeight(1)让标题文字占满剩余空间。
完整工程已上传 AtomGit:https://atomgit.com/OpenHarmonyPCDeveloper
克隆后用 DevEco Studio 打开,连鸿蒙PC模拟器直接 Run,首页点"可拖拽便签"即可。
七、在鸿蒙PC模拟器上验证(截图怎么做)完成编译如下:

运行效果如下:


切换输入法输入文字如下:

八、还能怎么扩展
便签跑通后,自然的下一步:多便签层叠管理、双击标题栏最大化、内容同步到分布式KV(见我另一篇分布式笔记)。这些都不难,核心的"窗口是可控对象"思维已经建立。
小结:PC 应用和手机应用最大的不同,就是"窗口"成了你要直接操作的对象。把 moveWindowTo 的坐标坑、preferences 的持久化、最小化/置顶这三件事做对,你就拿到了鸿蒙PC桌面开发的入场券。欢迎把便签改得更好看,推到 AtomGit 一起共建开源鸿蒙PC生态。
更多推荐



所有评论(0)