鸿蒙截图工具开发实战 01:整体架构——29 个 ets 文件是怎么组织起来的

我在鸿蒙 PC(2in1)上从零写了一个截图工具 PinShot:全局快捷键截屏、框选标注、取色放大镜、贴图悬浮、OCR 文字识别、托盘常驻。这个系列把整个项目拆成 24 篇,每篇讲透一个技术点,代码全部来自真实可运行的工程。第一篇先把架构地基交代清楚:模块怎么分层、窗口体系怎么设计、启动链路里藏着什么时序陷阱。

工程结构:五个目录,各管一摊

entry/src/main/ets
├── entryability/     # EntryAbility:生命周期与启动路由
├── pages/            # 页面:Index(主窗)、CapturePage(截图态)、
│                     #   PinImagePage(贴图)、OcrResultPage(OCR)、LongShot*(长截图)
├── components/       # 大组件:CaptureView(截图编辑核心, ~4000行)、SettingsView(设置页)
├── capture/          # 截图领域逻辑:ScreenCapturer、SelectionModel、WindowDetector
├── common/           # 基础设施:WindowMgr、PcTrayMgr、ThemeManager、
│                     #   Clipboard、FileSaver、ShareUtil、OcrUtil、DeviceUtil
└── store/            # 持久化:SettingsStore(设置)、HistoryStore(截图历史)

分层原则只有一条:pages 依赖 components 依赖 capture/common/store,反向依赖禁止。CaptureView 是全工程最大的文件(近 4000 行),截图编辑的选区、标注、放大镜、工具栏全在里面——后面会有整整 10 篇在拆它。

窗口体系:一个主窗 + 三类子窗

这个应用的本质是窗口应用,UI 复杂度全在窗口关系上:

窗口 载体 特点
主窗口 Index(设置页) 自绘标题栏,PC 上可隐回托盘
截图子窗 CapturePage 全屏、置顶,覆盖桌面进入截图态
贴图子窗 PinImagePage 无边框、可拖拽缩放,多实例并存
OCR 子窗 OcrResultPage 居中浮窗,可最大化

所有子窗口的创建、销毁、几何变换收口在 WindowMgr 单例(866 行)里。收口是刻意的:HarmonyOS 的 window.createWindow / moveWindowTo / resize 每个 API 单独看都简单,但四种窗口的进出场顺序互相牵制(比如 OCR 关闭时要不要恢复主窗,取决于有没有贴图活着),分散在各页面里必然失控。

跨窗口通信两条通道:

  • emitter 事件总线:同进程内的动作分发,如快捷键触发截屏 emitter.emit({ eventId: EVENT_PC_CAPTURE })
  • AppStorage:跨窗口传数据,如把待识别的 PixelMap 塞给 OCR 窗(KEY_OCR_PIXELMAP)、托盘态标志(KEY_OCR_FROM_TRAY)。

一条经验:emitter 不跨进程。托盘左键面板运行在独立进程(StatusBarViewExtensionAbility),它想触发截屏必须 startAbility 携带 parameters,由主 Ability 的 onNewWant 路由:

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  const params = want.parameters as Record<string, Object> | undefined;
  const action = params ? String(params['action'] ?? '') : '';
  if (action === 'capture') {
    emitter.emit({ eventId: EVENT_PC_CAPTURE });
  } else if (action === 'exit') {
    this.context.getApplicationContext().killAllProcesses();
  }
}

启动链路:onCreate 里的顺序是有讲究的

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  // 设置项持久化初始化必须最先执行(主题/快捷键等后续逻辑依赖)
  SettingsStore.init(this.context);
  // 按持久化主题模式应用应用级颜色模式(默认 system 交回系统)
  ThemeManager.apply(this.context, SettingsStore.themeMode());
  if (isPC()) {
    PcTrayMgr.getInstance().init(this.context);
    this.startupHidePending = SettingsStore.firstRun() ? false : SettingsStore.startupHide();
  }
}

三个顺序约束:

  1. SettingsStore.init 必须第一个——主题模式、快捷键组合都从它读;
  2. ThemeManager.apply 要在任何窗口内容加载前执行,否则首帧闪一下错误主题;
  3. isPC() 守卫包住所有 PC 专属逻辑(托盘、快捷键),平板走零托盘的普通形态——设备分流用能力判断而不是散落的 ifisPC() 内部就一行 deviceInfo.deviceType === '2in1'

第一个时序陷阱:冷启动直接进托盘

需求:PC 上开机自启后不弹主窗,直接驻留托盘。第一版实现写在 loadContent 回调里调 hideAbility()——失败。原因写在类的第一个字段注释里:

/** PC:启动后首次进前台时隐藏到托盘(hideAbility 要求 Ability 处于前台,
    loadContent 回调时机过早会失败) */
private startupHidePending = false;

hideAbility 要求 ability 已经在前台,而 loadContent 回调时前台切换还没完成。正确做法是把"要隐藏"记成 pending 标志,挪到 onForeground 里执行——即便如此,前台状态刚切换的瞬间 hideAbility 仍可能短暂不可用,所以还带了一个 300ms 间隔的重试:

private hideToTrayWithRetry(retries: number): void {
  this.context.hideAbility().then(() => {
    hilog.info(DOMAIN, 'testTag', 'started into tray');
  }).catch((e: Object) => {
    if (retries > 0) {
      setTimeout(() => {
        this.hideToTrayWithRetry(retries - 1);
      }, 300);
    }
  });
}

生命周期回调的"时机"是这个项目反复出现的主题:API 能不能调成功,取决于你站在生命周期的哪个点上。这句话后面还会应验很多次。

点关闭不退出:onPrepareToTerminate

托盘应用的另一个标配:点窗口 × 是隐藏,不是退出。HarmonyOS 给的钩子是 onPrepareToTerminate,返回 true 表示"我拦截了这次退出":

onPrepareToTerminate(): boolean {
  if (isPC() && SettingsStore.closeToTray()) {
    this.context.hideAbility().then(() => {
      hilog.info(DOMAIN, 'testTag', 'hidden to tray on close');
    });
    return true;
  }
  return false;
}

注意它需要 ohos.permission.PREPARE_APP_TERMINATE 权限(module.json5 声明即可,非用户授权型)。真正的退出走托盘菜单,killAllProcesses() 一步到位——托盘图标随进程消失,不需要(也没法在无前台窗口时)调 removeFromStatusBar。

主窗口即设置页:自绘标题栏 + 左侧导航,PC 形态的“工具应用”长相

全系列地图

24 篇按链路推进,每篇一个可独立阅读的技术点:

  • 截屏链路(02~04):权限的申请与静默检查 → 截屏 API 与 PixelMap → 全屏子窗口与 Dock 避让
  • 选区交互(05~08):状态机 → 自定义鼠标指针 → 窗口检测 → 全屏选区与右键后退
  • 标注系统(09~14):撤销栈 → 基础形状与工具栏 → 箭头 → 文字 → 序号与手写笔 → 马赛克
  • 取色放大镜(15~16):像素读取的 BGRA 陷阱 → Canvas 网格绘制
  • 输出(17~18):离屏合成保存 → 剪贴板/文件/分享/历史
  • 贴图(19~20):子窗口方案 → 交互与生命周期
  • OCR(21~22):端侧识别与排版 → 结果窗口
  • 地基与收官(23~24):全局快捷键与托盘 → 主题适配与上架 AGC

下一篇进正题:截屏权限 CUSTOM_SCREEN_CAPTURE——为什么"检查权限"和"申请权限"必须是两个函数,以及托盘态下它们各自的正确姿势。
与排版 → 结果窗口

  • 地基与收官(23~24):全局快捷键与托盘 → 主题适配与上架 AGC

下一篇进正题:截屏权限 CUSTOM_SCREEN_CAPTURE——为什么"检查权限"和"申请权限"必须是两个函数,以及托盘态下它们各自的正确姿势。

Logo

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

更多推荐