鸿蒙截图工具开发实战01-整体架构-29个ets文件是怎么组织起来的-online
鸿蒙截图工具开发实战 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();
}
}
三个顺序约束:
SettingsStore.init必须第一个——主题模式、快捷键组合都从它读;ThemeManager.apply要在任何窗口内容加载前执行,否则首帧闪一下错误主题;isPC()守卫包住所有 PC 专属逻辑(托盘、快捷键),平板走零托盘的普通形态——设备分流用能力判断而不是散落的 if,isPC()内部就一行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。

全系列地图
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——为什么"检查权限"和"申请权限"必须是两个函数,以及托盘态下它们各自的正确姿势。
更多推荐



所有评论(0)