鸿蒙PC 窗口与桌面基础交互实战:用 ArkUI 写一个「启台 Launchpad」,把窗口动作做成可搜索的命令
鸿蒙PC 窗口与桌面基础交互实战:用 ArkUI 写一个「启台 Launchpad」,把窗口动作做成可搜索的命令
欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
前言:手机上的应用只有一个窗口,全屏就是全部;到了鸿蒙PC,“窗口”第一次变成了一个可以被用户随手拖、随手贴、随手收起的实体。开发者要回答的问题也随之变成了三个:窗口动作从哪里唤起、动作作用在哪个窗口上、动作做完之后用户怎么知道自己做对了。本文用一个纯 ArkTS 的小工程「启台 Launchpad」回答这三个问题:它把“贴左半屏、双栏并置、最大化、收起、关闭、居中、保存快照”七种 PC 窗口动作,和“打开应用、切换系统设置、搜索文件”三类桌面实体,统一成一张可搜索的结果列表;把当前打开的窗口做成带焦点态的对象,让键盘与鼠标都能精确指向其中一个。文中所有代码逐行核对自配套工程
Launchpad_ohos,四张截图都是鸿蒙PC 真机的完整桌面截图;最后一章把这套“命令 + 焦点窗口”的模型放进 ArkUI 跨平台框架(ArkUI-X)、Compose Multiplatform 与 Flutter 三套跨平台框架里对照,说明哪些设计可以跨框架带走、哪些必须按平台重写。
关键词:鸿蒙PC、鸿蒙 PC、开源鸿蒙PC、窗口与桌面基础交互、命令面板、窗口动作、焦点窗口、贴靠半屏、双栏并置、ArkUI、ArkTS、ArkUI 跨平台框架、ArkUI-X、Compose Multiplatform 跨平台框架、Flutter 跨平台框架、2in1、AtomGit。
一、先把边界说清楚
写桌面交互的文章,最怕两件事:把设计稿讲成实机、把演示数据讲成实测数据。所以开头先把三件事摊开。
第一,四张截图都是鸿蒙PC 真机的完整桌面,不是裁剪后的应用窗口。 每张图都能看到三样东西:最上方 DevEco Code 的标题栏(Launchp... 工程标签、版本控制、安装 DevEco Code、工程结构工具条),最下方鸿蒙PC 的系统 Dock(一排应用图标)与右侧系统托盘(网络、音量、电量、输入法、下午 4:51 / 11月15日),中间前台运行的才是本文的主角「启台 Launchpad」。系统 Dock 与系统托盘同时入镜,是“这是鸿蒙PC”最直接的证据,读者不需要相信任何文字描述。
第二,配套工程是一套「桌面交互实验台」,不是系统级窗口管理器。 工程目录 项目源码/Launchpad_ohos:AppScope/app.json5 里 bundleName 为 com.launchpad.spotlight,vendor Launchpad Lab,versionCode 1000000;entry/src/main/module.json5 只声明 deviceTypes: ['2in1'],EntryAbility 挂了 entity.system.home 与 action.system.home(即可以被设为桌面启动器入口);build-profile.json5 里 compatibleSdkVersion 与 targetSdkVersion 均为 26.0.0(HarmonyOS,API 26)。全工程 8 个 .ets 文件,纯 ArkTS + ArkUI 声明式 UI、Stage 模型,entry/oh-package.json5 的 dependencies 是空的——零三方依赖,连图标都是自己用 Path 画的。
第三,“窗口”在这个工程里是数据对象,不是系统窗口。 这是本文必须最先承认的一点,也是它作为“实验台”的合理性:common/Models.ets 里的 WindowCard 只有六个字段(id / appId / title / subtitle / tone / state),state 是一个字符串枚举(open / minimized / left / right / max / centered / split)。所谓“贴靠左半屏”,是把这个卡片的状态改成 'left' 并在界面上反映出来,没有真的调用 window 模块去移动任何系统窗口。entry/src/main/ets/entryability/EntryAbility.ets 全文 21 行,只做了 windowStage.loadContent('pages/Index', ...) 加一行日志。
为什么这么设计?因为桌面交互最难的从来不是“调哪个 API”,而是状态机怎么设计、焦点怎么表达、动作结果怎么回显。把系统窗口替换成一个数据对象,这三个问题反而暴露得更干净:状态迁移可以单测,焦点可以描述,结果可以回放。真要接系统窗口,第七章给出一份约 50 行、可以直接粘贴进 EntryAbility 的接法,以及接上之后必须改的三处语义。
边界讲完,本文要讨论的东西就聚焦了:在鸿蒙PC 上,怎样用 ArkUI 把“窗口动作”这件原本散落在标题栏按钮、右键菜单、快捷键里的能力,收敛成一个可搜索、有焦点、有反馈的统一入口。
二、鸿蒙PC 真机完整桌面截图:四张图逐张读
图 1:窗口动作页——七种 PC 动作排成一列

图 1 鸿蒙PC 真机完整桌面:顶部是 DevEco Code 标题栏,底部是系统 Dock 与托盘;前台「启台 Launchpad」停在“窗口动作”分类,列表里七条动作各带一个键位徽标(⌘← / ⌘→ / ⌘⌥2 / ⌘↑ / ⌘M / ⌘W / ⌘⌥C / ⌘⇧S),右侧是桌面预览与最近执行,底部是三个桌面窗口卡片
整个界面是一张 860 vp 宽的命令面板,加上左右两条辅助栏。分四层读:
第一层是顶部行。 左侧是品牌区:一枚琥珀色 ◎ 圆环、标题「启台 Launchpad」、一行小字「命令面板与桌面启动实验台」;右侧是状态区:一枚胶囊写着「☾ 勿扰中」(绿色,表示勿扰模式开启中)和一个实时时钟 16:50。这一行高度 60 vp(LaunchMetrics.topRail),它的作用是把“这台机器现在处于什么状态”和“我在用什么工具”分开摆放,眼睛一扫就知道。
第二层是左侧范围栏(232 vp)。 分三块:范围 下有「全部 24 / 应用 6 / 窗口动作 8 / 系统设置 5 / 文件 4」五条分类,每条右侧带数量;收藏 下有「★ Atlas Reader」与「★ 双栏并置」两条(各带一个 ✕ 可摘星);最下方固定一块「快捷键」说明:Ctrl K · 呼出面板、↑ ↓ · 选择结果、Enter · 执行。这块说明是命令面板的自我说明书,桌面用户第一次打开就知道怎么用键盘。
第三层是中间结果区(自适应宽度)。 最上方是一枚粗琥珀描边的搜索框(高 58 vp、圆角 16、投影 18),占位文案是「搜索应用、动作、设置与文件…」,左侧一枚放大镜。往下是结果列表,每行高 58 vp(LaunchMetrics.resultH),结构固定为:字形图标(42 vp 宽的彩色符号,此处 ◧ ◨ ◫ ⤢ — ✕ ⊕ ⧉)+ 标题与副标题 + 类型标签(蓝色“动作”、绿色“应用”、琥珀色“设置”、珊瑚色“文件”)+ 键位徽标 + 星标。类型标签的颜色来自一处集中定义(kindTone()),所以扫一眼颜色就知道这行是动作还是应用,不需要读文字。
第四层是右侧面板(300 vp)与底部窗口条。 右栏上半是「桌面预览」:一块 140 vp 高的圆角矩形,用当前壁纸色渲染,底部三个小圆点代表当前打开的三个窗口(收起的显示为灰色);下面是壁纸名与明暗「雾蓝 · 浅色」;再下面是「最近执行」列表,五条记录都是“时间 + 动作 + 结果”,比如 14:18 · 双栏并置 · 已完成、13:56 · 保存桌面快照 · 已保存。中间区最底部是「桌面窗口 · 点击选中焦点」+ 三张窗口卡片:Muse Canvas(打开)、Atlas Reader(打开)、Kernel Shell(已收起,带“还原”按钮)。图 1 里第三张卡是加粗描边、带白底的——它就是当前焦点窗口,底部“收起当前窗口”和“关闭当前窗口”两条动作作用的对象。
图 2:设置类结果与深色桌面——一次动作的完整回路

图 2 鸿蒙PC 真机完整桌面:分类切到“系统设置”,五条设置项以琥珀色标签呈现;右栏桌面预览已切换为石墨深色,最近执行首条为 16:51 · 桌面已转深色
图 2 是本工程最值得看的一张,因为它展示了一次完整的动作回路:
- 用户在左栏点「系统设置」,结果区从 8 条动作换成 5 条设置(
勿扰模式 / 深色桌面 / 显示最近记录 / 搜索包含文件 / 更换桌面壁纸); - 用户点「深色桌面」,
runItem()把darkDesktop取反,返回结果文案桌面已转深色; - 右栏「桌面预览」的底色立刻从
#D9E6EE换成#1E242A,下方文字变成「石墨 · 深色」; - 同一条动作被写进「最近执行」列表首位,带时间
16:51,颜色用设置类的琥珀色。
这四步对应四个函数,全在 pages/Index.ets 里:SideCategory() 切 filterKind、runItem() 分发动作、log() 写记录、build() 读 wallpaper 与 darkDesktop 渲染预览。没有任何一步需要重新加载页面,也没有任何一步用到全局事件总线——因为所有状态都在同一个 @State 里。
截图里还有个容易忽略的细节:图 2 的窗口卡片变成了「Muse Canvas / Atlas Reader / Kernel Shell」,且 Kernel Shell 是选中态(加粗蓝边),而它的按钮是“收起”而不是“还原”——说明此刻 Kernel Shell 已被还原为打开状态。这正是“焦点是数据”的好处:焦点迁移与状态迁移是两条独立的线,可以任意组合,不需要为每种组合写一套 UI。
图 3:文件类别——把“桌面上的东西”也纳入搜索

图 3 鸿蒙PC 真机完整桌面:分类切到“文件”,四条文件结果(route.json / 第一章草稿.md / 封面.heif / HarmonyOS 栅格规范.md)以珊瑚色“文件”标签呈现,行尾不显示星标
图 3 说明“命令面板”这个概念在桌面上的边界更宽:桌面上能被“执行”的东西不只应用与动作,还包括文件、设置、甚至一段脚本。工程用 kind 把它们分成四类:
| kind | 标签 | 颜色 | 可收藏 | 执行语义 |
|---|---|---|---|---|
app | 应用 | 青绿 #0F3D4A | 是 | 打开一个窗口(openWindowFor) |
action | 动作 | 蓝 #2E7D8C | 是 | 作用于焦点窗口(applyWindowAction) |
setting | 设置 | 琥珀 #D6A244 | 是 | 切换一个开关(dnd / dark / recent / files / wallpaper) |
file | 文件 | 珊瑚 #E4573D | 否 | 用关联应用打开(openAtlas / openMuse) |
“文件不可收藏”是刻意的:文件路径会变,收藏一个 route.json,第二天可能就 404 了;而收藏一个动作或应用永远有效。这条规则在代码里体现为 ResultRow 中的一句 if (item.kind !== 'file')——产品规则落到代码上,往往只是这样一个条件。
图 3 还有一个真实的不一致必须点出来:左栏「文件 4」这个计数来自 countOf('file'),它统计的是 this.items 里所有 kind === 'file' 的条目,不受“搜索包含文件”开关影响;而结果区的 filtered() 在 includeFiles 为 false 时会把这些条目全部过滤掉。也就是说,关掉“搜索包含文件”之后,左栏依然会写「文件 4」,点进去却是空列表。这是计数口径与列表口径没有对齐的典型问题,第十章会给出两行修法。
图 4:全部——24 条结果的排序与检索

图 4 鸿蒙PC 真机完整桌面:分类切到“全部”,24 条结果混排——六条应用、两条窗口动作、最近执行首条为 13:12 · 开启勿扰模式
图 4 是“全部”视图,也是最能看出排序策略的一张:前六条是应用(Muse Canvas → Index 资料库),接着是窗口动作(贴靠左半屏、贴靠右半屏、双栏并置……),再往下才是设置与文件。这个顺序不是按字母,也不是按热度,而是按 createItems() 里的书写顺序——即数组顺序就是优先级顺序。
这个选择很朴素,但值得坚持:命令面板类工具的默认排序,最忌讳“猜”。按使用频率排序会让列表在每次执行后跳动,用户第二次想点同一行时位置已经变了;按字母排序会把“打开某个应用”排到“贴靠左半屏”后面,不符合桌面习惯。固定顺序 + 强类型筛选 + 拼音搜索,是这个尺寸下的最优解,也让“第 3 条是双栏并置”这种肌肉记忆得以建立。
图 4 里还能看到检索的完整口径:filtered() 把四类字段拼成一个干草堆(title + pinyin + keys + subtitle)再 toLowerCase().includes(q)。所以搜「shuanglan」能命中“双栏并置”(拼音),搜「⌘⌥2」能命中同一条(键位),搜「zsh」能命中 Kernel Shell(副标题)。拼音、键位、描述都参与检索,是中文桌面工具必须做的一件事——用户不会记得应用的完整中文名,但他记得自己敲过什么。
三、模型先行:一条命令、一个窗口,都是能打字描述的记录
桌面交互的第一性问题不是“怎么画”,而是“要操作的东西该怎么描述”。工程把这件事压缩成三个 interface,全部放在 common/Models.ets,没有任何方法、没有任何函数引用——纯数据。
// entry/src/main/ets/common/Models.ets
export interface LaunchItem {
id: string; // 唯一标识,如 'act_snap_left'
kind: string; // app / action / setting / file
title: string; // 展示标题,如 '贴靠左半屏'
subtitle: string; // 一行说明,同时参与检索
glyph: string; // 字形名,映射到矢量图标
tone: string; // 强调色
keys: string; // 键位徽标,如 '⌘←'
actionId: string; // 执行分支的判别值
desc: string; // 预览栏的详细说明
pinyin: string; // 拼音串,专供中文检索
}
export interface WindowCard {
id: number; // 窗口实例 id(同一个应用可开多个)
appId: string; // 所属应用,用于防重复开窗
title: string;
subtitle: string;
tone: string;
state: string; // open / minimized / left / right / max / centered / split
}
export interface RecentEntry {
id: number;
title: string;
subtitle: string;
at: string; // 'HH:mm'
result: string; // 执行结果文案
tone: string;
}
三个设计决策藏在这三十行里。
决策一:kind 与 actionId 分开。 初学者容易把两者合并成一个字段,但那会立刻出问题:「打开 Muse Canvas」和「打开 route.json」都叫“打开”,kind 分别是 app 与 file,界面上标签颜色不同、是否可收藏不同;而执行分支其实都走“打开”。合并之后,kindTone() 与 filtered() 都要改用 actionId 判断,标签体系就崩了。分类(给用户看的)与动作(给代码看的)是两个维度,必须两个字段。
决策二:state 是字符串枚举,不是数字。 数字枚举省 4 个字节、省一次 map 查找,但它有一个致命副作用:写到日志或持久化之后,state: 3 这一行没人看得懂。字符串 'left' 看一眼就知道是“贴左半屏”。命令面板的每一次执行都要落到“最近执行”里给用户看,可读性比那点性能重要得多。
决策三:pinyin 是一等字段。 中文桌面工具的搜索,如果不做拼音,用户就必须准确输入中文;而拼音输入的中间态(如 shuang)往往正是用户按下回车前的最后一刻。把拼音串显式存进数据里,比在运行时做汉字转拼音便宜一百倍,也不需要引入任何三方库——这对“零依赖工程”是硬约束。
有了模型,动作表就是一张纯数组。工程里 24 条命令,其中 8 条 action 是本文的主线:
// entry/src/main/ets/common/Models.ets(节选)
{ id: 'act_snap_left', kind: 'action', title: '贴靠左半屏',
subtitle: '窗口动作 · 当前焦点窗口', glyph: 'snapLeft', tone: LaunchColors.blue,
keys: '⌘←', actionId: 'snapLeft',
desc: '将焦点窗口吸附到桌面左半屏,保留 16 vp 边距。',
pinyin: 'snap left zuoban ping' },
{ id: 'act_split', kind: 'action', title: '双栏并置',
subtitle: '窗口动作 · 两个最近使用窗口', glyph: 'split', tone: LaunchColors.teal,
keys: '⌘⌥2', actionId: 'split',
desc: '把最近两个窗口等宽并置,适合阅读与笔记对照。',
pinyin: 'split shuanglan bingzhi' },
{ id: 'act_minimize', kind: 'action', title: '收起当前窗口',
subtitle: '窗口动作 · 进入 Dock', glyph: 'minus', tone: LaunchColors.amber,
keys: '⌘M', actionId: 'minimize',
desc: '窗口收起到桌面 Dock,状态与位置保留。',
pinyin: 'minimize shouqi' }
注意这里的 desc:“将焦点窗口吸附到桌面左半屏,保留 16 vp 边距”。这句话在当前的实现里并没有对应代码——applyWindowAction() 只改 state,不改任何几何量。它是给未来接系统窗口时留的契约。文档里写下的承诺,要么有代码兑现,要么明确标成待办,本文第七章会把这条 16 vp 边距真的算出来。
四、焦点:桌面交互里唯一必须显式建模的状态
手机不需要“焦点”概念——一次只有一个界面,指哪打哪。桌面必须要有,因为同一时刻屏幕上可能并列着几个窗口,用户说“把它贴到左边”时,“它”是谁?
工程的做法是:focusId 是一个数字,指向 windowCards 里某一张卡的 id。
// entry/src/main/ets/pages/Index.ets
@State windowCards: WindowCard[] = createWindowCards();
@State focusId: number = 1;
focusedCard(): WindowCard | undefined {
for (let i = 0; i < this.windowCards.length; i++) {
if (this.windowCards[i].id === this.focusId) {
return this.windowCards[i];
}
}
return undefined;
}
焦点用 id 存,不用索引存。 这是个看似微小、实则决定性的选择。如果 focusId 存的是数组下标 0/1/2,那么一旦用户关掉中间那个窗口(closeCard() 用 filter 重建数组),下标会整体前移,焦点会“跳到”另一个窗口上——用户明明关的是 Kernel Shell,焦点却落到了 Post 邮件。用 id 存,filter 之后 id 不变,焦点要么仍然指向原窗口,要么指向一个已经不存在的 id(此时 focusedCard() 返回 undefined),两种都是可预期的。这条经验在鸿蒙PC 上尤其重要,因为窗口是会被用户随手关的。
焦点用返回值表达“没有”,而不是抛异常。 focusedCard() 的返回类型是 WindowCard | undefined,调用方必须显式处理:
} else {
const target = this.focusedCard();
if (target !== undefined) {
this.applyWindowAction(target, item.actionId);
result = '作用于「' + target.title + '」';
} else {
result = '暂无窗口可操作';
}
}
暂无窗口可操作 这七个字,是整个工程里最容易被低估的一行。桌面上的窗口全关掉之后,用户点“贴靠左半屏”不该无反应、更不该崩溃,而应该得到一句明确的拒绝。所有作用域是“当前焦点”的桌面动作,都必须有这条兜底分支。
焦点态在视觉上怎么表达?三个地方同时响应,构成一组“焦点三件套”:
// WindowChip:焦点窗口加粗描边 + 白底
.backgroundColor(this.focusId === card.id ? LaunchColors.surface : LaunchColors.paper)
.border({
width: this.focusId === card.id ? 2 : 1,
color: this.focusId === card.id ? LaunchColors.teal : LaunchColors.line
})
// 标题文字:焦点窗口用主墨色,非焦点用次级墨色
.fontColor(this.focusId === card.id ? LaunchColors.ink : LaunchColors.inkSoft)
// 状态文字:焦点窗口用青绿,非焦点用淡灰
.fontColor(this.focusId === card.id ? LaunchColors.teal : LaunchColors.faint)
三处对齐,用户不需要找:边框变粗、文字变深、状态变亮,三件事同时发生,焦点窗口在图 1 里一眼可辨。这三处用的是同一个判断表达式 this.focusId === card.id,重复了三次——这是可接受的重复,因为它带来的不是维护负担而是视觉一致性:改焦点口径时,你会在同一个函数里连改三行,不会漏掉某处。
还有一个容易被忽视的细节:新建窗口时焦点自动转移。
openWindowFor(item: LaunchItem): void {
const exists = this.windowCards.some((c: WindowCard): boolean => c.appId === item.id);
if (!exists && item.kind === 'app') {
const card: WindowCard = {
id: this.nextId, appId: item.id,
title: item.title.replace('打开 ', ''),
subtitle: '已打开 · 刚刚', tone: item.tone, state: 'open'
};
this.nextId = this.nextId + 1;
this.windowCards = this.windowCards.concat([card]);
this.focusId = card.id; // 新窗口即焦点
}
}
两个守卫条件值得展开。exists 判断的是 appId 而不是 id:同一个应用再次执行“打开”,不该在桌面开出第二个窗口(这在真实系统里通常表现为“把已有窗口提到前台”)。item.kind === 'app' 则挡掉了文件与动作——在图 3 里点 route.json,走的是 runItem() 里 actionId === 'openAtlas' 的分支,它也会调 openWindowFor(),但传进去的 item kind 是 file,所以不会新建窗口,只会把焦点留在原地(若 Atlas 已开着,则什么都不做)。这条分支真实的行为是“没有反应”,是本章要标出的第二处诚实边界。
五、状态迁移:七种窗口动作,一个纯函数
窗口动作的核心是一个 switch。工程的 applyWindowAction() 长这样(略去无关行):
// entry/src/main/ets/pages/Index.ets
applyWindowAction(card: WindowCard, action: string): void {
let next = card.state;
if (action === 'minimize') {
next = 'minimized';
} else if (action === 'restore') {
next = 'open';
} else if (action === 'left') {
next = 'left';
} else if (action === 'right') {
next = 'right';
} else if (action === 'max') {
next = 'max';
} else if (action === 'center') {
next = 'centered';
} else if (action === 'split') {
next = 'split';
}
this.windowCards = this.windowCards.map((c: WindowCard): WindowCard => {
if (c.id === card.id) {
const moved: WindowCard = {
id: c.id, appId: c.appId, title: c.title,
subtitle: c.subtitle, tone: c.tone, state: next
};
return moved;
}
return c;
});
}
这段代码有四个必须讲清楚的点,其中两个是 ArkUI 特有的约束。
第一,map 里必须构造新对象,不能原地改字段。 如果把 c.state = next 直接写在 map 里,windowCards 数组的引用没变,ArkUI 的 @State 依赖的是对象引用的浅比较,改动可能不会触发重绘,或者只触发一部分(取决于 ForEach 的键生成器)。工程选择“整体替换元素”,是最稳的写法——在 ArkUI 里,状态更新的成本不在写代码,而在“什么时候没更新”,所以宁可多构造几个对象。
第二,ForEach 的键生成器把 state 拼进去了。 这是极其关键的一行:
ForEach(this.windowCards, (card: WindowCard) => {
this.WindowChip(card)
}, (card: WindowCard) => card.id.toString() + card.state)
键是 '1open'、'3minimized' 这样的拼接串。为什么?因为 WindowChip 里根据 state 决定渲染“还原”还是“收起”按钮。如果键只写 id,ArkUI 会认为这一项“没有变化”而复用旧节点,按钮就换不过来——你会看到状态文字变了、按钮没变。把 state 拼进键,等于告诉框架“状态变了,请重建这一项”。代价是每次状态迁移都会销毁重建一个卡片,对一个只有几张卡片的桌面来说完全可以接受;但如果窗口数上百,这个写法会让滚动掉帧,那时应该改成在 WindowChip 内部用 @Prop state 驱动局部刷新。
第三,动作名与状态名不是同一个词表。 actionId 里的 snapLeft 落到 state 上叫 left;maximize 落到 state 上叫 max;center 落到 state 上叫 centered。这个映射是 if / else if 逐条翻译出来的。它长得笨,但有一个好处:将来的动作可以在不改状态词表的前提下增加,比如再加一个“贴到左上角”,state 仍然可以是 'topLeft',与 actionId: 'snapTopLeft' 解耦。
第四,restore 是从 minimized 回来的唯一入口。 窗口卡片上的「收起 / 还原」按钮直接调 applyWindowAction(card, 'minimize') 或 'restore',而命令列表里的“收起当前窗口”调的是 'minimize'。也就是说,同一个状态迁移有两条入口:一条是点卡片上的按钮(对那一张卡生效),一条是执行命令(对焦点卡生效)。两条入口共用同一个函数,是这份代码最好的设计之一——它保证了“用哪种方式操作,结果都一样”。反例很常见:菜单里一个函数、右键一个函数,最后两份状态机漂移了。
状态与视觉的对应关系集中在 stateLabel() 里,纯粹是中文文案映射:
// entry/src/main/ets/pages/Index.ets
function stateLabel(state: string): string {
switch (state) {
case 'open': return '打开';
case 'minimized': return '已收起';
case 'left': return '贴左半屏';
case 'right': return '贴右半屏';
case 'max': return '已最大化';
case 'centered': return '已居中';
case 'split': return '双栏之一';
default: return state;
}
}
default: return state; 这一行是防御性写法:如果将来加了新状态却忘了加文案,界面上会显示 topLeft 这种原始值,而不是空白。空白会让开发者以为“没渲染”,原始值会让他立刻知道“漏了一处文案”。
这里还有第三处诚实边界:applyWindowAction() 只是改了 state 字符串,并没有联动“桌面预览”里那几个圆点的位置。预览区只根据 state === 'minimized' 决定圆点是灰是彩:
Circle({ width: 12, height: 12 })
.fill(card.state === 'minimized' ? LaunchColors.faint : card.tone)
所以贴了左半屏的窗口,在预览里仍然是一个居中的圆点。这不算 bug,只能算“预览的表达力不足”;要让它真的能看出贴靠,得把 WindowCard 扩展出 x / y / w / h 四个几何字段,并在预览区按比例摆放矩形——这正是第七章接系统窗口时要做的第一件事。
六、执行回路:一个 runItem 管住四种异构动作
四种 kind 的执行语义完全不同:应用是“建窗口”,设置是“翻开关”,动作是“改焦点窗口状态”,文件是“交给关联应用”。工程把它们全部收进一个 runItem():
// entry/src/main/ets/pages/Index.ets
runItem(item: LaunchItem): void {
const at = clockText();
let result = '已执行';
if (item.kind === 'app' || item.actionId === 'open' ||
item.actionId === 'openAtlas' || item.actionId === 'openMuse') {
result = '已打开';
this.openWindowFor(item);
} else if (item.actionId === 'dnd') {
this.dnd = !this.dnd;
result = this.dnd ? '已开启' : '已关闭';
} else if (item.actionId === 'dark') {
this.darkDesktop = !this.darkDesktop;
result = this.darkDesktop ? '桌面已转深色' : '桌面已转浅色';
} else if (item.actionId === 'recent') {
this.showRecent = !this.showRecent;
result = this.showRecent ? '已开启' : '已关闭';
} else if (item.actionId === 'files') {
this.includeFiles = !this.includeFiles;
result = this.includeFiles ? '已开启' : '已关闭';
} else if (item.actionId === 'wallpaper') {
this.wallpaper = (this.wallpaper + 1) % WALLPAPERS.length;
result = '已切换为 ' + WALLPAPERS[this.wallpaper];
} else {
const target = this.focusedCard();
if (target !== undefined) {
this.applyWindowAction(target, item.actionId);
result = '作用于「' + target.title + '」';
} else {
result = '暂无窗口可操作';
}
}
const entry: RecentEntry = {
id: this.nextId, title: item.title,
subtitle: kindLabel(item.kind) + ' · ' + item.subtitle,
at: at, result: result, tone: kindTone(item.kind)
};
this.nextId = this.nextId + 1;
this.log(entry);
}
result 先赋默认值,再被各分支覆盖,最后统一写日志。 这个顺序是刻意的:无论走哪个分支,最后都必须有一条 RecentEntry 落下;如果写成“每个分支自己写日志”,那么新增一个分支忘了写日志,用户就会遇到“点了没反应”的错觉。工程用“一个出口”解决,代价是每个分支要多写一行 result = ...。
log() 里有一条长度上限:
log(entry: RecentEntry): void {
this.recents = [entry].concat(this.recents).slice(0, 10);
}
[entry].concat(this.recents) 而不是 [entry, ...this.recents]——在 ArkTS 里,展开语法对数组字面量有额外的类型推导开销,concat 更直接。slice(0, 10) 是硬上限:命令面板的“最近执行”是给人看的时间线,超过十条就该滚动,不该无限增长。
nextId 是一个单调递增的共享计数器,同时给窗口、记录、新命令用。它不做回绕,也不复用已删除的 id。好处是 ForEach 的键永不复用,不会出现“新记录继承了旧记录的重绘动画”这种诡异现象;坏处是长时间运行后数字会变大——但对一个计数器来说,Number.MAX_SAFE_INTEGER 是一个天文数字。
图 2 和图 4 的差别恰好演示了这条链路的可观测性:图 2 的“最近执行”首条是 16:51 · 桌面已转深色,图 4 的首条是 13:12 · 开启勿扰模式——两张截图是同一台机器上的不同操作轨迹,每条轨迹都能从列表反推用户做过什么。这正是桌面工具应该有的“操作可回放”能力:用户忘了自己刚才关掉了哪个开关时,右栏能告诉他。
七、把实验台接到真窗口:五个接点与一次真实换算
前面所有讨论都建立在“窗口是数据对象”上。要让“贴靠左半屏”真的把系统窗口推到左半屏,需要在 EntryAbility 里补一段接线。下面是增量代码(未打进截图里的那个 HAP),读者可以直接粘贴。
// entry/src/main/ets/entryability/EntryAbility.ets(改造后)
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { display, window } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;
const SNAP_GAP = 16; // 与 Models.ets 里 desc 承诺的 16 vp 边距保持一致
export default class EntryAbility extends UIAbility {
private mainWin: window.Window | undefined = undefined;
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'Launchpad', 'loadContent failed: %{public}s', JSON.stringify(err));
}
});
this.mainWin = windowStage.getMainWindowSync();
// 接点 1:最小尺寸兜底。setWindowLimits 的单位是 px,不是 vp
const density = display.getDefaultDisplaySync().densityPixels;
this.mainWin.setWindowLimits(Math.round(320 * density), Math.round(240 * density))
.catch((e: BusinessError) => hilog.error(DOMAIN, 'Launchpad', 'limits: %{public}s', e.message));
// 接点 2:尺寸变化 → 换算成 vp 写进 AppStorage,页面侧用 @StorageLink 吃
this.mainWin.on('windowSizeChange', (size: window.Size) => {
const d = display.getDefaultDisplaySync().densityPixels; // 每次重取,屏幕切换后会变
AppStorage.setOrCreate<number>('lpWinW', Math.round(size.width / d));
AppStorage.setOrCreate<number>('lpWinH', Math.round(size.height / d));
});
// 接点 3:窗口状态变化(最大化 / 还原 / 分屏 / 最小化)
this.mainWin.on('windowStatusChange', (state: window.WindowStatusType) => {
hilog.info(DOMAIN, 'Launchpad', 'status: %{public}d', state);
AppStorage.setOrCreate<number>('lpWinStatus', state);
});
// 接点 4:让系统记住窗口位置与大小,下次启动恢复
windowStage.setWindowRectAutoSave(true)
.catch((e: BusinessError) => hilog.error(DOMAIN, 'Launchpad', 'autoSave: %{public}s', e.message));
}
// 接点 5:把“贴靠左半屏”真正算成一组矩形
async snapToLeft(): Promise<void> {
if (this.mainWin === undefined) { return; }
const d = display.getDefaultDisplaySync().densityPixels;
const area = this.mainWin.getWindowProperties().drawableRect; // 可绘制区域,不是 windowRect
const halfW = Math.floor((area.width - SNAP_GAP * 2 * d) / 2);
await this.mainWin.resize(Math.round(halfW / d), Math.round((area.height - SNAP_GAP * 2 * d) / d));
await this.mainWin.moveWindowTo(Math.round((area.left + SNAP_GAP * d) / d),
Math.round((area.top + SNAP_GAP * d) / d));
}
onWindowStageDestroy(): void {
this.mainWin?.off('windowSizeChange');
this.mainWin?.off('windowStatusChange');
}
}
页面侧只需要三行,就能把真实窗口尺寸喂给现有的数据模型:
// entry/src/main/ets/pages/Index.ets 片段
@StorageLink('lpWinW') @Watch('onRealResize') private realWinW: number = 1024;
@StorageLink('lpWinH') @Watch('onRealResize') private realWinH: number = 720;
private onRealResize(): void {
// 真实窗口尺寸参与“双栏并置”的可行性判断,例如窗口过窄时禁止并置
this.canSplit = this.realWinW >= 900;
}
这套接线里有三个坑,都是只有真正接过一次才会记住的。
坑一:resize 与 moveWindowTo 的单位是 vp,而 drawableRect 是 px。 这是鸿蒙PC 上最容易犯的错。getWindowProperties() 返回的矩形以 px 计(受屏幕密度影响),而 resize() / moveWindowTo() 接受 vp。如果直接把 area.width / 2 丢给 resize,在 150% 缩放的鸿蒙PC 上窗口会变成应有的 1.5 倍宽。上面代码里 / d 与 * d 成对出现,就是为了守住这条口径。换算必须成对写在相邻两行,否则半年后没人看得懂。
坑二:断点判定应该看 drawableRect,不是 windowRect。 windowRect 包含系统装饰(标题栏与边框),drawableRect 才是给应用画内容的区域。对于“窗口能不能放下双栏并置”这种判断,差 30–40 vp 就可能误判。同理,接点 2 里 windowSizeChange 给出的尺寸也包含装饰,真要精确判断内容宽,应该用 drawableRect.width。
坑三:densityPixels 不能缓存。 把窗口从内置屏拖到外接屏、或系统缩放从 100% 调到 125%,densityPixels 会变。如果在 onWindowStageCreate 里取一次存成成员变量,那么换屏之后所有换算都会偏移。上面代码里每次回调都重新 getDefaultDisplaySync(),是唯一正确的写法。
接上这五个接点之后,第一到六章的所有逻辑一行都不用改——WindowCard.state 从“纯 UI 状态”变成“UI 状态 + 系统窗口状态的镜像”,applyWindowAction() 在写 state 的同时调用 snapToLeft() 之类的系统 API 即可。这就是把交互建模成数据的好处:渲染层与系统层之间只隔着一个状态机。
八、只有桌面才存在的那几件事:一份鸿蒙PC 交互清单
把手机经验直接搬到鸿蒙PC,会漏掉一整层东西。下面七条,是这个工程里真实处理过、或明确留白的桌面交互细节。
1)鼠标悬停必须给反馈,但不能改几何尺寸。 鼠标的位置采样频率远高于点击,用户在自己按下之前需要先知道“这里能点”。工程把 hover 收进一个复用的按钮组件里:
// entry/src/main/ets/components/LaunchControls.ets
@Component
export struct LaunchBtn {
@Prop icon: string = 'plus';
@Prop label: string = '';
@Prop active: boolean = false;
@Prop filled: boolean = false;
@Prop tone: string = LaunchColors.teal;
@Prop btnSize: number = 30;
@Prop iconSize: number = 14;
action: () => void = () => {};
@State private hovered: boolean = false;
build() {
Row({ space: 6 }) {
LaunchIcon({
name: this.icon, iconSize: this.iconSize,
iconColor: this.filled ? LaunchColors.surface :
this.active ? this.tone : this.hovered ? LaunchColors.ink : LaunchColors.muted,
stroke: 1.6
})
if (this.label.length > 0) {
Text(this.label)
.fontSize(11)
.fontColor(this.filled ? LaunchColors.surface : (this.active ? this.tone : LaunchColors.muted))
}
}
.height(this.btnSize)
.padding({ left: this.label.length > 0 ? 10 : 0, right: this.label.length > 0 ? 12 : 0 })
.constraintSize({ minWidth: this.btnSize })
.justifyContent(FlexAlign.Center)
.backgroundColor(this.filled ? this.tone :
this.active ? LaunchColors.tealSoft :
this.hovered ? LaunchColors.canvasDeep : Color.Transparent)
.borderRadius(7)
.border({
width: this.filled ? 0 : 1,
color: this.active ? this.tone : this.hovered ? LaunchColors.lineStrong : Color.Transparent
})
.onHover((inside: boolean) => { this.hovered = inside; })
.onClick(() => this.action())
}
}
注意它只改了两样:底色(Color.Transparent → canvasDeep)与描边(Transparent → lineStrong)。没有 scale、没有阴影动画、没有位移。 因为桌面上同时可见的可点对象往往有几十个,全都做缩放动效会变成视觉噪音;而且缩放会改变节点的排版盒子,在 Row 里可能引发相邻元素抖动。hover 只改颜色,不改几何,这条纪律值得写进团队的交互规范。
2)键盘是桌面的第一输入方式,所以必须真的接。 左栏固定展示了三条快捷键(Ctrl K 呼出面板、↑ ↓ 选择结果、Enter 执行),工具栏和状态栏里也有键帽徽标(⌘K、⏎、esc)。但必须诚实说明:当前 Index.ets 没有挂 onKeyEvent,这些键帽是“承诺”,不是“能力”。 真实的可执行路径是点击,键盘方向键并不能移动选择。真要接上,最小实现是给根容器补这三件事:
// entry/src/main/ets/pages/Index.ets 片段:让键盘真正生效
@State private keyIndex: number = 0;
Column() {
// ... 面板内容 ...
}
.focusable(true)
.defaultFocus(true) // 首帧就拿焦点,否则 onKeyEvent 收不到任何按键
.onKeyEvent((event: KeyEvent) => {
if (event.type !== KeyType.Down) { return; }
const list = this.filtered();
if (list.length === 0) { return; }
if (event.keyCode === 2012) { // 方向键下
this.keyIndex = Math.min(this.keyIndex + 1, list.length - 1);
} else if (event.keyCode === 2013) { // 方向键上
this.keyIndex = Math.max(this.keyIndex - 1, 0);
} else if (event.keyCode === 2054) { // 回车
this.runItem(list[this.keyIndex]);
}
})
这里有三个真实障碍,一个都不能省:focusable(true) 让容器可获得焦点;defaultFocus(true) 让它在首帧自动获得焦点(缺了这一步,用户必须先点一下界面才能按键,体验极差);KeyType.Down 过滤是为了避免按下与抬起各触发一次。键码在不同键盘布局与输入法状态下是否稳定,必须在鸿蒙PC 真机上逐个验证——这三行不能只靠文档推,必须实测。
3)焦点必须在键盘与鼠标之间保持同一套语义。 上面那段代码引入了 keyIndex,如果就这么写,它会与 focusId 形成两套并行的“当前选中”概念:用键盘选中第 3 条、用鼠标点第 5 条,两者会打架。正确做法是让键盘与鼠标共用同一个真源——键盘只负责移动 keyIndex,runItem() 执行后同步把焦点窗口设为被操作对象。桌面交互里,“焦点”这个概念只能有一份数据,否则一定会出现“高亮在 A、动作作用在 B”的恐怖场景。
4)点击行与双击行应该是两件事。 手机上的行点击就是执行;桌面上,一次点击往往只表示“选中 / 预览”,双击才表示“执行”。工程在列表行上正是这么分的:
// entry/src/main/ets/components/CommandPalette.ets
.onClick(() => this.onSelect(item.id)) // 单击:选中 + 预览
.gesture(TapGesture({ count: 2 }).onAction(() => this.onExecute(item))) // 双击:执行
这条区分是桌面与移动端最本质的交互差异之一。它带来的连带要求是:必须有一块区域显示“当前选中项是什么”,否则单击就等于没反馈。 工程用右侧 262 vp 宽的预览栏承担这件事——图标、标题、类型、说明、键位、一个微缩窗口示意,最后是“执行”按钮与收藏按钮。
5)结果反馈要有时间戳,不能只有一句话。 “已完成”三个字说完就消失,用户无法回看。工程把它落进右栏的“最近执行”列表,每条带 HH:mm:
export function clockText(): string {
const now = new Date();
return ('0' + now.getHours()).slice(-2) + ':' + ('0' + now.getMinutes()).slice(-2);
}
('0' + n).slice(-2) 是给 9 补成 09 的经典写法,比 padStart 更短,也不依赖 ES2017 的字符串方法。桌面工具的操作历史是产品能力,不是调试信息。
6)执行失败的文案必须区分“做完了”与“做不了”。 前面讲过的 暂无窗口可操作 是一类;还有一类是“目标存在但当前不适用”,比如窗口已经很窄却要并置双栏。工程目前只有前者,后者是待补项。桌面工具最忌讳静默失败:用户点了一个按钮,屏幕上什么都没变,他会以为软件卡了,然后连点三次。
7)桌面预览是自检工具,不是装饰。 右栏那块 140 vp 高的矩形,用当前壁纸色渲染、用圆点表示活着的窗口(收起的变灰)。它承担的功能是“我此刻的桌面长什么样”——这在命令面板里尤其必要,因为面板遮住了大半个屏幕,用户看不见自己刚贴到左边的窗口。任何覆盖全屏的工具,都该给自己留一个“我改了什么”的缩略视图。
九、跨平台框架对照:这套“命令 + 焦点”能带走多少
如果要把「启台 Launchpad」这套交互搬到 ArkUI 跨平台框架(ArkUI-X)上,或者团队同时还在用 Compose Multiplatform、Flutter 这类跨平台框架,最该问的问题是:哪些是资产,哪些是绑定?
先给结论:命令表、状态机、焦点模型、执行回路是资产;尺寸订阅、窗口装饰、手势语义、键位分发是绑定。
| 关注点 | 鸿蒙PC 原生 ArkUI(本工程) | ArkUI 跨平台框架 | Compose Multiplatform 跨平台框架 | Flutter 跨平台框架 |
|---|---|---|---|---|
| 命令与窗口的数据模型 | LaunchItem / WindowCard 纯接口 | 可原样复用,字段一一对应 | data class 直接平移 | class + copy 平移 |
| 状态迁移函数 | applyWindowAction() 纯函数 | 可原样复用 | 可原样复用 | 可原样复用 |
| 列表重建更新 | @State + ForEach 键拼 state | 语义一致 | mutableStateListOf 按引用比较 | ValueKey('$id-$state') 同理 |
| 窗口尺寸来源 | window.on('windowSizeChange') | 按目标端能力子集桥接 | BoxWithConstraints / 窗口容器 | MediaQuery.sizeOf(context) |
| 真实窗口移动 | resize / moveWindowTo(vp) | 各端差异最大,通常不保证 | 桌面端有独立窗口 API | 桌面端有独立窗口 API |
| 键位分发 | onKeyEvent + focusable / defaultFocus | 保持一致 | onKeyEvent on focusable | Shortcuts + Focus |
| hover 反馈 | .onHover((inside: boolean) => ...) | 保持一致(有鼠标的端) | Modifier.hoverable + interactionSource | MouseRegion |
几个必须说透的对照点。
第一,模型与状态机是零成本迁移的。 LaunchItem 十个字段、WindowCard 六个字段、一个 switch 加一次 map,换成 Kotlin 只改语法关键字:
// Compose Multiplatform 跨平台框架:同一套状态迁移,语法平移
data class WindowCard(
val id: Int, val appId: String, val title: String,
val subtitle: String, val tone: String, val state: String
)
fun applyWindowAction(list: List<WindowCard>, targetId: Int, action: String): List<WindowCard> {
val next = when (action) {
“minimize” -> “minimized”
“restore” -> “open”
“left” -> “left”
“right” -> “right”
“max” -> “max”
“center” -> “centered”
“split” -> “split”
else -> return list
}
return list.map { if (it.id == targetId) it.copy(state = next) else it }
}
这段代码有一个额外收获:它把“未知动作”显式地变成“原样返回”(else -> return list),比 ArkTS 版本里 let next = card.state 再逐条覆盖更不容易出错。跨平台重写时顺手发现原实现的弱点,是这类迁移最常见的副产品。
第二,列表重建的“键”是跨框架都会踩的同一个坑。 ArkUI 里 ForEach 的键生成器如果不拼 state,卡片上的“收起 / 还原”按钮会换不过来;Compose 里 items(list) { it.id } 不拼状态,remember 的局部状态同样会残留;Flutter 里 ValueKey(card.id) 不拼状态,State 会被复用。三个框架、同一个病因:列表项的身份标识没有包含影响它渲染的状态。 正确的写法在三者里分别是 card.id.toString() + card.state、items(list, key = { “${it.id}-${it.state}” })、ValueKey('${card.id}-${card.state}')——语义完全一致,只是语法不同。
第三,手势与键位的语义差异最大。 ArkUI 的 onClick 与 TapGesture({ count: 2 }) 用 .gesture() 组合,两者可以叠加在同一个节点上;Compose 里单击靠 Modifier.clickable、双击靠 Modifier.combinedClickable(onClick, onDoubleClick),后者只有一个入口,必须一次写完两个回调,否则会互相抢占;Flutter 里 GestureDetector 的 onTap 与 onDoubleTap 同时存在时,onTap 会被延迟约 300 ms 等待双击判定。这意味着“单击选中、双击执行”这个交互,在 Flutter 上天然带着一段延迟,必须靠 onTapDown 或自定义识别器才能消掉。 这类差异无法靠“翻译代码”解决,只能逐端实测。
第四,真实窗口控制的可用性差距最大,且差距在扩大而不是缩小。 鸿蒙PC 提供了 resize / moveWindowTo / setWindowLimits / setWindowRectAutoSave / windowStatusChange 这一整套 2in1 窗口能力,这是原生 ArkUI 在桌面端的强项;跨平台框架通常只能在“桌面目标端”提供类似能力,且各端 API 形态、单位(vp / dp / 逻辑像素)都不统一。所以跨平台方案里,窗口控制应当被抽成一个接口,各端各自实现:
// 建议的抽象:把“贴半屏”从具体 API 里摘出来
export interface WindowController {
snapLeft(): Promise<void>;
snapRight(): Promise<void>;
maximize(): Promise<void>;
minimize(): Promise<void>;
center(): Promise<void>;
readonly contentWidthVp: number;
}
页面只依赖 WindowController,ArkUI 侧实现走 @kit.ArkUI 的 window,Compose Multiplatform 侧实现走桌面窗口 API,Flutter 侧走 window_manager 之类的插件,测试时注入一个 FakeController 就能在无窗口环境里跑完整状态机测试。这条抽象是本文最推荐带走的东西——它让“桌面交互逻辑”与“哪个框架在渲染”彻底解耦。
第五,vp 口径必须显式声明,不能指望平台默认对齐。 鸿蒙用 vp、Android 用 dp、iOS 用 pt,在“密度无关”这一点上语义接近,所以 320 / 240 这类最小尺寸阈值可以平移;但“窗口内容区”的边界定义各平台不同(有的把状态栏算进去、有的不算)。跨平台框架一般会提供一个统一的尺寸抽象,它是否等于“可绘制内容区”,必须逐端拿尺子量一次,这正是第七章第二个坑的跨平台版本。
十、踩坑清单与复现步骤
10.1 八个坑,都是这份代码里真实存在的
坑 1:ForEach 的键不含状态,按钮换不过来。 症状是状态文字已变、“收起/还原”按钮还是旧的。修法:键里拼上 state(本文第五章),或者把按钮选择逻辑下沉到子组件里用 @Prop state 驱动。
坑 2:焦点用下标存,关窗后焦点漂移。 focusId 存数组下标时,filter 重建数组会让焦点落到别的窗口上。修法:存 id,focusedCard() 返回 undefined 时给明确文案(本文第四章)。
坑 3:计数口径与列表口径不一致。 图 3 里左栏写「文件 4」,但“搜索包含文件”关掉后点进去是空列表。修法有两行:
// 修法 A:让计数也遵守开关
countOf(kind: string): number {
let n = 0;
for (let i = 0; i < this.items.length; i++) {
const it = this.items[i];
if (kind !== 'all' && it.kind !== kind) { continue; }
if (!this.includeFiles && it.kind === 'file') { continue; } // 与 filtered() 同口径
n++;
}
return n;
}
// 修法 B:别让开关影响分类可见性,只在检索时排除
坑 4:resize / moveWindowTo 是 vp,drawableRect 是 px。 混用会让窗口在 150% 缩放下变成 1.5 倍宽。修法:换算成对写在相邻两行(本文第七章坑一)。
坑 5:densityPixels 缓存后换屏就错。 必须在回调里重新取 display,不能在 onWindowStageCreate 里存一次。
坑 6:defaultFocus 漏了,键盘完全不响应。 focusable(true) 只是“可获得焦点”,不点一下界面就拿不到。两个都要加,且 defaultFocus(true) 只能有一个节点声明。
坑 7:键帽是文案,不是能力。 左栏列了 Ctrl K / ↑ ↓ / Enter,但源码没接 onKeyEvent。要么补上第八章的实现,要么把这些键帽从界面上拿掉——留着不实现的键帽,比不显示键帽更伤信任。
坑 8:onHover 里改几何尺寸会导致邻居抖动。 鼠标进出的瞬间改变宽度或 scale,同一 Row 里左右元素会跟着移动。只改颜色与描边(本文第八章第 1 条)。
10.2 复现步骤
- DevEco Studio 打开
项目源码/Launchpad_ohos(compatibleSdkVersion26.0.0,deviceTypes: ['2in1'],无三方依赖,无需ohpm install拉包)。 - 在鸿蒙PC 真机上运行(或在 2in1 模拟器上运行,但截图请务必用真机完整桌面)。首屏即图 1 的“窗口动作”分类。
- 点左栏「系统设置」→「深色桌面」,观察右栏“桌面预览”由浅转深、下方文字变为「石墨 · 深色」、“最近执行”首条写入带时间的结果(对应图 2)。
- 点左栏「文件」,确认四条文件结果与珊瑚色标签,并确认行尾没有星标(对应图 3)。
- 点左栏「全部」,确认 24 条结果按
createItems()的书写顺序混排(对应图 4)。 - 在搜索框输入
shuanglan,确认命中“双栏并置”;输入⌘M,确认命中“收起当前窗口”;输入zsh,确认命中 Kernel Shell。 - 点底部任一窗口卡片切换焦点,确认描边加粗、文字变深、状态变亮三处同时响应;再执行“贴靠左半屏”,确认“最近执行”首条为
作用于「xxx」。 - 把所有窗口卡片点“关闭”清空,再执行“贴靠左半屏”,确认得到
暂无窗口可操作而不是无反应。 - 若要接真实窗口,按第七章改造
EntryAbility,并在applyWindowAction()内对left / right / max补充对应的系统调用;接入后重点验证 150% 缩放下的尺寸换算。
配套源码建议归档到 AtomGit 的 PC 社区项目组,作为“命令面板 + 窗口焦点”的可复用起始模板;新建项目入口见文首社区引导。
十一、结语:把窗口当作用户会说话的宾语
回到最开始那三个问题。
窗口动作从哪里唤起? 从一张可搜索的列表里。桌面上的能力一旦超过七八个,标题栏按钮、右键菜单、快捷键三条路径就会开始互相覆盖、互相遗漏;一张统一的命令表则不会漏——新增一个动作,只是往数组里加一条记录。
动作作用在哪个窗口上? 作用在一个被显式建模的焦点上。焦点用 id 存、用返回值表达“没有”、用三处视觉同时表达“在这里”。桌面交互里没有“隐式主语”,用户说“贴到左边”时,代码必须能立刻回答“贴谁”。
动作做完之后用户怎么知道? 从一条带时间戳的结果记录里知道。作用于「Atlas Reader」 比一个转圈动画有用得多,因为它可回看、可追溯,也能在用户怀疑“我刚干了什么”时给出答案。
这套东西与鸿蒙PC 的窗口系统其实只有一层之隔:第七章那五十行接线补上之后,WindowCard.state 就成了系统窗口状态的镜像,其余逻辑一行不动。这也是为什么先用数据对象做实验台值得:状态机一旦正确,接系统 API 只是“多写一个实现”,而不是“重写一遍逻辑”。
至于跨平台框架——ArkUI 跨平台框架、Compose Multiplatform 跨平台框架、Flutter 跨平台框架——能带走的正是这三件事:命令表、焦点模型、状态机;带不走的是窗口装饰、键位分发与手势语义。把能带走的抽成接口,把带不走的写成适配器,桌面交互就不再是每个平台重写一遍的苦活。
窗口在鸿蒙PC 上第一次变成了用户可以随手拖动的实体,但它在代码里,永远应该是一个可以被描述、被引用、被回放的宾语。
更多推荐



所有评论(0)