欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper


摘要:我把手边最常用的"桌面便签"搬到了开源鸿蒙PC上。这篇不是单纯讲 API,而是复盘我从"窗口拖拽都做不对"到做出一个能每天用的小工具的全过程:窗口移动的坐标坑、preferences 本地持久化、最小化与置顶。所有代码已在 DevEco Studio + 鸿蒙PC模拟器跑通,工程已传 AtomGit,文末附完整全屏截图清单。

我桌面上常年贴着一张便签,记待办、记灵感。换到鸿蒙PC后第一件事就是想把它复刻出来。本以为很简单——不就是个能拖动的文本框吗?结果真动手才发现,手机上"页面铺满全屏"的思维惯性,在PC上根本行不通。这篇把我踩的坑和最终能用的代码都交代清楚。

本文目录

  1. 一、先想清楚:PC 应用和手机应用差在哪
  2. 二、窗口拖拽:一个让我调试到半夜的坐标坑
  3. 三、让便签"记得住":preferences 持久化
  4. 四、像桌面工具一样:最小化与置顶
  5. 五、常见报错与排查
  6. 六、完整页面与工程结构
  7. 七、在鸿蒙PC模拟器上验证(截图怎么做)
  8. 八、还能怎么扩展

一、先想清楚:PC 应用和手机应用差在哪

手机 ArkUI 页面默认铺满全屏,你基本不用管"窗口"这个概念。但到了鸿蒙PC,应用是一个可以拖动、最小化、置顶、缩放的独立窗口。所以一个便签要解决四件事:

  1. 标题栏能拖着窗口满屏跑;
  2. 文本能编辑并关掉再打开还在;
  3. 能最小化、能置顶,像个正经桌面工具;
  4. 代码干净,模拟器一键跑通。

第 1 点靠 @ohos.window 的 moveWindowTo,第 2 点靠 preferences 用户首选项,第 3 点靠 minimize() 和 setWindowKeepAbove()。下面逐个讲。

动手前先把开发环境对齐,避免因为版本差异出现"我这边能跑、你那边报错"的情况。本文使用的环境如下:

项目版本 / 说明
开发工具DevEco Studio 5.0 及以上(建议升级到最新稳定版)
SDKHarmonyOS SDK(API 12 及以上,含 PC 设备能力)
运行设备鸿蒙PC模拟器(或本地 PC 镜像)
语言 / 框架ArkTS + ArkUI 声明式开发
关键 Kit@ohos.window(窗口)、@kit.ArkData(preferences)

本项目开发界面如下:
在这里插入图片描述

环境就绪后,新建一个 Empty Ability 工程,把后面的代码放进 pages/StickyNote.ets 即可。

整体上,一个便签应用在鸿蒙PC上的运行链路可以拆成三层:窗口层负责位置与状态,数据层负责内容落盘,UI 层负责交互。三者关系如下:

用户操作(拖拽/输入/点按钮)

UI 层:StickyNote.ets

窗口层:@ohos.window

数据层:preferences

moveWindowTo 移动窗口

minimize 最小化

setWindowKeepAbove 置顶

put + flush 写入

get 读取

本地键值存储 StickyNoteStore

理解这张图,后面每个坑都能对上号:拖拽问题出在窗口层,内容丢失问题出在数据层。

二、窗口拖拽:一个让我调试到半夜的坐标坑

最初我直接把鼠标事件的 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 接收的是屏幕绝对坐标,两者之间差了一个窗口左上角的位置:

鼠标事件 event.x / event.y
相对组件局部坐标

计算位移增量
dx = event.x - lastX

累加到窗口绝对坐标
posX += dx

moveWindowTo(posX, posY)
屏幕绝对坐标

窗口移动到新位置

更新 lastX / lastY
作为下一帧基准

关键点在于每帧都要更新 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 实例" "TextArea" "用户" "本地存储" "preferences 实例" "TextArea" "用户" 不调用 flush 只在内存,进程退出即丢失 输入文字 put('note_content', content) flush() 落盘 重新打开应用 getPreferences('StickyNoteStore') 读取键值 返回 note_content 回填到 TextArea

还有一个容易忽略的细节: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(true)"

"setWindowKeepAbove(false)"

"minimize()"

"minimize()"

"从任务栏恢复"

Normal

KeptAbove

Minimized

需要注意 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/catchconst 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生态。

Logo

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

更多推荐