鸿蒙 PC Markdown 编辑器多窗口工程:specified UIAbility、会话隔离与强杀恢复
鸿蒙 PC Markdown 编辑器多窗口工程:specified UIAbility、会话隔离与强杀恢复
桌面 Markdown 编辑器里的“新建窗口”看起来只是多开一份界面,真正实现时却会同时触碰实例身份、正文所有权、文件事实来源、异常退出、关闭确认、光标恢复和系统文件关联。单窗口程序可以把很多状态默认放在页面成员里;进入多窗口后,任何一处隐含的全局状态都可能让窗口甲覆盖窗口乙的未保存正文。
本文讨论一种面向鸿蒙 PC 的本地优先方案:使用 HarmonyOS Stage 模型的 specified UIAbility 表达系统窗口,用独立 LocalStorage 把窗口参数送入 ArkUI 页面,每个窗口拥有自己的 ArkWeb 与 CodeMirror 会话;应用沙箱只保存有界、可校验、可原子恢复的窗口记录。Markdown 文件继续是磁盘事实来源,会话 JSON 只负责恢复界面和未保存缓冲区。
文中代码来自 OhMarkdown 的 G4-06 实现。仓库地址是 https://gitcode.com/VON-/codex_md_oh,架构决策提交为 435bfc2,功能提交为 9581046。文章使用的验证环境是 DevEco Studio MateBook Pro 2in1 模拟器,HarmonyOS 6.1.1、API 24。模拟器证据不会被写成鸿蒙 PC 真机结论。

多窗口首先是所有权问题
多窗口最危险的实现方式,是保留一份全局 DocumentSession[],然后让多个窗口订阅它。这样做短期很省代码,但编辑器正文、撤销栈、选区、脏标记、自动保存和外部冲突会形成一组难以拆开的共享状态。窗口甲切换活动标签,窗口乙可能跟着切换;窗口乙写恢复记录,可能覆盖窗口甲;两个窗口同时保存同一 URI 时,又难以说明哪份基线属于谁。
更稳妥的规则是把所有权直接落到系统窗口:
- 一个 UIAbility 实例对应一个桌面窗口。
- 一个窗口拥有一份 WorkspaceShell、ArkWeb、CodeMirror、标签集合和焦点状态。
- 窗口之间不共享可变正文,不建立全局正文仓库。
- 文件一致性不靠内存广播,而靠磁盘事实来源、保存前重读和文件指纹检测。
- 全局只允许存在不携带正文的关闭回调注册表。
这条边界非常重要。它把“窗口隔离”从约定变成结构事实:只要正文没有被提升到 AppStorage 或模块级单例,两个窗口天然不会因为一次普通状态赋值互相覆盖。
为什么选择 specified UIAbility
HarmonyOS Stage 模型提供多种启动语义。无身份的多实例可以创建多个窗口,但重复打开同一文档时,系统不知道应该激活既有实例还是继续创建重复窗口。PC 编辑器需要可解释的身份:桌面图标重复点击应回到主窗口;用户明确点击“新建窗口”才产生新实例;同一文件从系统重复打开时应进入同一文档窗口。
specified UIAbility 允许 AbilityStage 在接收 Want 时返回实例键。OhMarkdown 使用三类键:
primary
window-<13 位毫秒时间>-<8 位十六进制随机后缀>
document-<URI 的 SHA-256 前 16 位>
Stage 入口的代码很短,因为它只负责身份,不承担会话恢复:
import { AbilityStage, Want } from '@kit.AbilityKit';
import { resolveWindowSessionId } from '../shared/services/WindowSessionService';
const WINDOW_SESSION_PARAMETER: string = 'ohmarkdown.windowSessionId';
export default class EntryAbilityStage extends AbilityStage {
onAcceptWant(want: Want): string {
const requested = want.parameters?.[WINDOW_SESSION_PARAMETER];
const requestedId = typeof requested === 'string' ? requested : '';
try {
return resolveWindowSessionId(requestedId, want.uri ?? '');
} catch (_) {
return 'primary';
}
}
}
从桌面启动时没有显式窗口参数,也没有文档 URI,结果固定为 primary。新建窗口命令传入生成的 window-*。文件关联没有窗口参数,但带 URI,于是使用确定性摘要。摘要不把用户路径直接写进系统实例键,也避免文件名相同导致冲突。
这里不能只用文件名。README.md 在不同目录里非常常见;即使使用完整 URI,也不适合直接成为沙箱文件名。SHA-256 的用途不是加密正文,而是把受限长度的 URI 映射为稳定、白名单友好的身份。
每个 UIAbility 必须有自己的 LocalStorage
多窗口第一版实现中最容易忽略的细节,是 loadContent 传入的 LocalStorage 如何到达页面。AppStorage 是进程级状态,适合系统主题或全局语言,不适合窗口 ID。窗口参数必须从 Ability 实例自己的 LocalStorage 进入页面,再显式传给工作台。
Ability 侧为每个实例创建存储:
class EntryPageStorageData {
windowSessionId: string = 'primary';
launchDocumentUri: string = '';
launchRequestSequence: number = 0;
}
export default class EntryAbility extends UIAbility {
private windowSessionId: string = 'primary';
private pageStorage: LocalStorage = new LocalStorage(new EntryPageStorageData());
onCreate(want: Want): void {
const requested = want.parameters?.['ohmarkdown.windowSessionId'];
this.windowSessionId = resolveWindowSessionId(
typeof requested === 'string' ? requested : '',
want.uri ?? ''
);
this.pageStorage.set('windowSessionId', this.windowSessionId);
this.pageStorage.set('launchDocumentUri', want.uri ?? '');
this.pageStorage.set('launchRequestSequence', want.uri ? 1 : 0);
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', this.pageStorage);
}
}
页面要声明使用共享存储,否则子组件可能得到默认值而不是 Ability 提供的那一份:
@Entry({ useSharedStorage: true })
@Component
struct Index {
@LocalStorageProp('windowSessionId')
private windowSessionId: string = 'primary';
@LocalStorageProp('launchDocumentUri')
private launchDocumentUri: string = '';
@LocalStorageProp('launchRequestSequence')
private launchRequestSequence: number = 0;
build() {
WorkspaceShell({
windowSessionId: this.windowSessionId,
launchDocumentUri: this.launchDocumentUri,
launchRequestSequence: this.launchRequestSequence
})
}
}
这个细节不是纯理论问题。模拟器第一次检查时,Ability 日志已经显示唯一 window-*,但 WorkspaceShell 仍打印 primary,说明页面没有接到实例存储。改成 @Entry({ useSharedStorage: true }) 并显式传递后,Ability 与 WorkspaceShell 日志中的 ID 一致。多窗口测试的价值就在这里:单窗口运行时,错误默认值恰好也是 primary,很难暴露。
窗口关闭不能只看当前标签
单窗口早期实现常用一个 documentDirty 判断是否需要关闭确认。多标签窗口中,这个判断不完整:当前标签可以是干净的,后台标签仍可能有未保存内容。窗口关闭必须遍历全部标签:
private hasUnsavedChanges(): boolean {
return this.documentSessions.some(
(session: DocumentSession): boolean => session.dirty
);
}
系统窗口关闭通过一个无正文的注册表转给对应 WorkspaceShell:
export type WindowCloseHandler = () => Promise<boolean>;
const WINDOW_CLOSE_HANDLERS: Map<string, WindowCloseHandler> =
new Map<string, WindowCloseHandler>();
export function registerWindowCloseHandler(
windowSessionId: string,
handler: WindowCloseHandler
): void {
WINDOW_CLOSE_HANDLERS.set(windowSessionId, handler);
}
export async function requestWindowClose(
windowSessionId: string
): Promise<boolean> {
const handler = WINDOW_CLOSE_HANDLERS.get(windowSessionId);
return handler ? handler() : false;
}
这不是通用事件总线。它不保存正文、不广播文档变化,也没有跨窗口同步。它只解决 UIAbility 的系统生命周期对象无法直接持有 ArkUI 组件方法的问题。
关闭状态机有三种脏窗口决策:取消、丢弃并关闭、保留并关闭。保留路径必须先捕获最新 CodeMirror 正文和选区,等待在途会话写结束,再原子提交 reopenOnLaunch=true。任何一步失败都返回“阻止关闭”。丢弃路径删除当前窗口会话与恢复记录,但不写 Markdown 文件。干净窗口保存 reopenOnLaunch=false 后直接关闭。
private async handleWindowCloseRequest(): Promise<boolean> {
if (this.windowCloseInProgress || this.operationInProgress) {
return true;
}
this.windowCloseInProgress = true;
try {
await this.captureActiveDocumentSession();
const hasDirtyDocuments = this.documentSessions.some(
(session: DocumentSession): boolean => session.dirty
);
if (!hasDirtyDocuments) {
await this.persistWindowSessionImmediately(false);
return false;
}
const result = await this.getUIContext().getPromptAction().showDialog({
title: $r('app.string.unsaved_window_title'),
message: $r('app.string.unsaved_window_message'),
buttons: [
{ text: $r('app.string.cancel_action'), color: '#34404B' },
{ text: $r('app.string.discard_window_action'), color: '#B42318' },
{ text: $r('app.string.keep_window_session_action'), color: '#087A63' }
]
});
if (result.index === 0 || result.index < 0) return true;
if (result.index === 1) {
await deleteWindowSessionRecord(context.filesDir, this.windowSessionId);
await clearRecoveryRecord(context.filesDir, this.windowSessionId);
return false;
}
await this.persistWindowSessionImmediately(true);
return false;
} catch (error) {
return true;
} finally {
this.windowCloseInProgress = false;
}
}
返回值语义在实现前必须通过目标系统验证。当前 HarmonyOS 2in1 模拟器上,返回 true 会取消系统关闭,返回 false 才允许窗口销毁。错误理解这个布尔值,会把“写失败保护”变成“写失败仍关闭”的数据丢失路径。
会话记录只恢复上下文,不接管文件
窗口会话格式固定为版本 1。它保存足以重建工作台的状态,但不成为新的文档数据库:
export interface WindowSessionRecord {
version: number;
windowSessionId: string;
activeDocumentSessionId: string;
workspaceRootUri: string;
activePanel: string;
sidebarOpen: boolean;
sidebarWidth: number;
viewMode: string;
syncScrollEnabled: boolean;
reopenOnLaunch: boolean;
updatedAt: number;
documents: Array<WindowDocumentSessionRecord>;
}
标签记录有一个关键选择:干净且带 URI 的标签不重复存正文,重启时必须从磁盘读取;未保存标签或脏标签才保存当前正文。脏的已保存标签还保存 persistedContent,让恢复后能解释本地缓冲区与磁盘基线的关系。
const documents = this.documentSessions.map((session) => {
const includeContent = session.dirty || session.uri.length === 0;
return {
id: session.id,
uri: session.uri,
name: session.name,
content: includeContent ? session.content : undefined,
persistedContent: session.dirty ? session.persistedContent : undefined,
hasUtf8Bom: session.format.hasUtf8Bom,
lineEnding: session.format.lineEnding,
revision: session.revision,
dirty: session.dirty,
fingerprint: session.fingerprint,
selectionAnchor: session.selectionAnchor,
selectionHead: session.selectionHead
};
});
这条规则防止会话缓存悄悄变成第二事实来源。如果磁盘上的干净文档后来被其他程序修改,重新启动时用户应该看到新磁盘内容,而不是几天前的会话副本。只有未保存编辑需要由应用沙箱保护。
有界校验要在写入前完成
会话里包含用户正文,不能只依赖 JSON 可以序列化。服务层必须校验 ID、字符串长度、标签数量、选区、枚举值和总容量。当前边界是:
- 每个窗口最多十二个标签。
- 单份正文或保存基线最多 5 MiB 字符。
- 一个窗口内正文与基线累计最多 20 MiB 字符。
- JSON 文件最多 32 MiB 字节。
- 侧栏宽度只接受 220 至 480 vp。
- 模式只接受 source、instant、split、preview。
- 会话文件只接受 primary、window-、document- 白名单名称。
- 最多自动重建 primary 加七个次窗口。
选区也必须受正文长度约束。否则一份损坏记录可能把光标恢复到不存在的位置,Web 侧再触发异常:
const maximumSelection = record.content?.length ??
MAX_WINDOW_SESSION_CONTENT_CHARACTERS;
return record.selectionAnchor <= maximumSelection &&
record.selectionHead <= maximumSelection;
Web Bridge 仍做第二层限制。原生记录是持久化信任边界,CodeMirror 则是运行时边界,两层职责不同:
function setDocumentSelection(anchor: number, head: number): boolean {
if (!Number.isInteger(anchor) || !Number.isInteger(head)) {
return false;
}
const safeAnchor = Math.max(0, Math.min(editor.state.doc.length, anchor));
const safeHead = Math.max(0, Math.min(editor.state.doc.length, head));
editor.dispatch({
selection: { anchor: safeAnchor, head: safeHead },
scrollIntoView: true
});
return true;
}
原子写入还要处理目录枚举
可靠写入不能等同于“写一个临时文件再 rename”。完整事务包括循环写入、fsync、备份旧文件、提交新文件和最终大小检查。写入 API 可能发生短写,因此需要按返回字节数继续:
let written: number = 0;
while (written < data.byteLength) {
const remaining = written === 0 ? data : data.slice(written);
const count = await fileIo.write(file.fd, remaining);
if (count <= 0) {
throw new Error('The window session write made no progress.');
}
written += count;
}
await fileIo.truncate(file.fd, written);
await fileIo.fsync(file.fd);
事务路径为 session.json.new -> session.json,已有最终文件先改名为 session.json.bak。如果进程在旧文件改名后、临时文件提交前被强杀,目录里只剩 .bak。只在已知 ID 的 load() 中恢复不够,因为主窗口重启时正是通过目录枚举发现次窗口;枚举如果只看 .json,会漏掉这份可恢复记录。
因此枚举同时识别严格白名单下的 .json、.json.new 和 .json.bak,提取基础 ID,再调用统一恢复函数。只有 .bak 时恢复为最终文件;只有未提交 .new 时清理;最终文件与备份同时存在且最终文件是完整普通文件时删除旧备份。
async function listWindowSessionIds(directory: string): Promise<Array<string>> {
const ids: Set<string> = new Set<string>();
for (const name of await fileIo.listFile(directory)) {
if (!WINDOW_SESSION_TRANSACTION_FILE_PATTERN.test(name)) {
continue;
}
ids.add(name.slice(0, name.indexOf('.json')));
}
return Array.from(ids);
}
这个修复来自提交前实现审查,而不是视觉测试。视觉上两个窗口已经可以恢复,但故障注入显示:没有最终文件时,原有枚举无法发现备份。最终 ohosTest 直接把次窗口最终文件重命名为 .bak,再调用重开枚举,断言最终文件被恢复;同时创建孤立 .new,断言枚举后临时文件被删除。
强杀恢复与正常关闭必须区分
正常关闭和进程强杀不能使用同一语义。用户关闭干净窗口,通常不希望下次启动重新出现;进程被系统回收时,用户没有表达关闭意图,窗口应该恢复。
OhMarkdown 在窗口活动期间持续写 reopenOnLaunch=true。正常关闭时才完成最后一次事务:干净或丢弃路径写 false/删除,保留路径继续写 true。强杀不会走窗口关闭回调,因此最后一份有效记录仍为 true。主窗口冷启动后枚举这些记录,用原 specified ID 逐个启动。
private async reopenInterruptedWindows(): Promise<void> {
const windowSessionIds = await listReopenableWindowSessionIds(
this.context.filesDir
);
for (const windowSessionId of windowSessionIds) {
await this.context.startAbility({
bundleName: 'com.example.ohmarkdown',
abilityName: 'EntryAbility',
parameters: {
'ohmarkdown.windowSessionId': windowSessionId
}
});
}
}
重开列表优先保留最近更新的七个次窗口,并按时间从旧到新启动,使最近活动窗口最后出现。已经运行的窗口记录不会为了满足静态上限而被静默删除;超量活动窗口属于后续容量与兼容专项,而不是通过丢正文来“达标”。
恢复时重新确认磁盘事实
恢复窗口记录时,不能把所有标签直接按 JSON 加载。每个标签需要独立决策:
- 干净、有 URI:从磁盘重读正文、BOM、换行和指纹。
- 干净、文件不可读:跳过该标签,不用旧缓存冒充磁盘。
- 脏、有 URI:恢复沙箱正文,同时读取磁盘并比较保存时指纹。
- 脏且指纹变化:保留本地正文,立即进入外部冲突状态。
- 未保存标签:从沙箱正文恢复,不产生伪造 URI。
if (document.uri.length > 0) {
const diskDocument = await readUtf8Document(document.uri);
if (document.dirty) {
if (document.fingerprint &&
!isSameDocumentFingerprint(document.fingerprint, diskDocument.fingerprint)) {
restoredConflicts.set(document.id, diskDocument);
} else {
fingerprint = diskDocument.fingerprint;
}
persistedContent ??= diskDocument.content;
} else {
content = diskDocument.content;
persistedContent = diskDocument.content;
documentFormat = diskDocument.format;
fingerprint = diskDocument.fingerprint;
}
}
多窗口没有引入跨窗口保存锁。两个窗口打开同一 URI 时,每个窗口仍拥有独立缓冲区;一方保存后,另一方通过两秒指纹轮询发现变化。若另一方已编辑,则进入三方冲突,不自动覆盖。这个策略让磁盘继续承担窗口之间的事实协调,避免把正文同步协议塞进 G4-06。
文件关联是声明、身份与权限三件事
HAP 中声明 ohos.want.action.viewData 只是第一步。OhMarkdown 当前声明 file/datashare 下的 text/markdown 和 text/plain,安装后 bm dump 已显示对应 skill 与 launchMode=2。运行时仍调用扩展名白名单,只接受 .md、.markdown、.mdown、.mkd 和 .txt。
第二步是稳定身份:同一 URI 生成同一 document-*,系统再次发送 Want 时进入既有实例的 onNewWant。页面使用递增请求序号触发打开动作,避免相同字符串因为响应式框架没有变化而漏处理。
第三步是读取权限。MIME 声明不能代替 URI 授权,datashare 权限也不能由应用自行假设。最终读取仍经过 Core File Kit、UTF-8 校验、大小限制和现有安全保存链。模拟器 bundle dump 只能证明声明进入 HAP,不能证明真实文件管理器的默认应用选择、持久授权和重复打开体验;这些必须在鸿蒙 PC 真机上单独验收。
测试如何覆盖多窗口的真实风险
Web 自动化新增两个低层契约。第一项捕获 CodeMirror 正文、anchor 与 head,恢复时验证越界位置被限制。第二项验证中英文命令面板只发送 newWindow 白名单命令,不允许 Web 自己调用任意系统能力。最终 Playwright 为 57/57,耗时 48.8 秒。
ArkTS 单元构建覆盖随机窗口 ID、同 URI 确定性 ID、非法视图模式和选区越界。ohosTest 在真实应用沙箱写入 primary 与次窗口会话,注入缺失最终文件、只剩 .bak 和孤立 .new,再验证恢复、重开列表、关闭标记与删除。最终模拟器套件 14/14,Failure 0、Error 0,总耗时 2534 ms。
第一次运行使用测试框架默认 5 秒单用例预算,1000 文件既有压力测试超时,未完成清理又影响下一项链接测试,结果是 12/14。显式设置 20 秒单用例预算后,压力用例约 2 秒,完整套件通过;功能修正后再次重跑仍为 14/14。保留这段记录很重要,因为可靠性报告不应把环境预算问题藏成“一次全部通过”。
模拟器人工任务包括:
- 创建第二个系统窗口并核对 Ability 与 WorkspaceShell 使用相同唯一 ID。
- 次窗口输入独立未保存标题,主窗口正文保持原样。
- 强停进程后重启,主窗口与次窗口按原身份重建。
- 检查次窗口未保存正文和脏标记仍在。
- 关闭脏窗口后选择取消,窗口继续存在。
- 再次关闭并选择保留,窗口关闭;强停重启后又能恢复。
- 安装最终 HAP 后用
bm dump核对 specified 启动模式与 viewData 声明。
最终产物为:单 HTML 7,689,401 字节,Debug HAP 8,771,637 字节,ohosTest HAP 9,466,198 字节。构建只有既有的未签名提示和 ohosTest 资源重复声明提示,没有新增 ArkTS 编译告警。
当前边界与后续工程
G4-06 完成的是单进程 specified UIAbility 多窗口基线,不是跨设备会话平台。它明确不提供云同步、接续、团队协作、全局正文仓库或应用卸载后的恢复。会话里含有未保存正文,因此只写应用沙箱,不发送网络,也不进入日志。
仍需后续验证的项目包括:
- 鸿蒙 PC 真机的系统文件管理器双击和重复打开同一文件。
- 两个真机窗口同时打开同一 URI,一方保存、另一方脏编辑的冲突任务。
- 多显示器、缩放、休眠唤醒和屏幕移除后的窗口几何恢复。
- 物理键盘、触控板、输入法候选窗与窗口焦点切换。
- 大量同时活动窗口、20 MiB 会话预算和异常断电压力。
- Release 签名、长期稳定期与竞品相同语料任务计时。
如果真机证明 specified UIAbility 会跨进程运行,现有同进程文件事务就需要重新评估进程级锁;如果系统文件关联不能稳定提供持久 URI,也要复核文档窗口 ID 和权限模型。这两种情况都不是局部补丁,应回到 ADR 重新确认。
结语
鸿蒙 PC Markdown 编辑器的多窗口优势,不是窗口数量,而是每个窗口都能独立承担真实工作,同时在关闭、强杀和磁盘变化时给出可解释结果。specified UIAbility 解决系统实例身份,LocalStorage 解决页面参数隔离,WindowSessionService 解决有界恢复,既有文件指纹状态机继续保护磁盘事实来源。
当这些边界同时成立时,“新建窗口”才不只是视觉上的第二个壳:窗口甲的正文不会被窗口乙覆盖,未保存缓冲区能在强杀后回来,保留写入失败会阻止关闭,损坏或中断的会话不会污染用户 Markdown。对桌面编辑器而言,这些行为比单纯多开一个界面更接近真正可依赖的生产力工具。
更多推荐



所有评论(0)