鸿蒙权限模型实战:Markdown 图片跟随文档的五层落位设计(spike 实验推翻两条铁律)
Markdown 文档里插入图片,有个经典难题:图片文件放哪。放全局图库,文档拷给别人图片就断链;放文档旁的 assets/ 文件夹,文档整个文件夹拷走,图片随行可用。MarkPin 选择了后者,但"往文档旁边写文件"这件事,在鸿蒙的权限模型下远比想象复杂——同一个操作,在不同位置的文档上,权限行为完全不同。这篇讲我们的五层落位设计与两次被 spike 实验推翻的"铁律"。
一、问题定义:一个操作,五种权限处境
粘贴/拖入一张图片,要把它写到"当前文档所在目录的 assets 文件夹"。但文档可能在这些位置:
- 应用沙箱内(新建未保存的文档);
- 已授权的工作区文件夹(用户通过"打开文件夹"授过权);
- 系统公共目录(文档/下载/桌面);
- 单独打开的文件(只有这一个文件的授权,父目录不可写);
- 未命名文档(压根没有目录)。
五种处境对应五种权限能力:有的能直接静默写,有的要先申请权限,有的完全没有写权限。如果每种处境都弹一次窗、问一次用户,体验就碎了。设计目标因此定为两条:能静默的全部静默;不能静默的,把弹窗推迟到"保存"这个用户本来就要做的动作里。
二、分析方法论:spike 实验,推翻"铁律"
动手设计前,我们先做了两轮 spike 实验(最小可行性验证)——事实证明这一步价值连城,因为项目早期记录里有两条"铁律",全被实验推翻了:
- 铁律一:"对文档目录只有文件级 URI 授权,父目录不可写。"实验结果:通过目录级授权(选择文件夹模式)后,目录以路径形式可直接静默写入;
- 铁律二:"写公共目录必须申请权限。"实验结果:当前系统版本上公共目录写入未强制权限(低版本 API 完全放行,高版本失败码 201 后补授权重试即可)。
spike 的做法很朴素:写一个最小验证程序,对每种目标位置分别尝试"建目录 → 写文件 → 读回校验",记录真实行为。两次推翻说明一件事:权限文档描述的是"应该怎样",spike 揭示的是"实际怎样"——设计必须基于后者。spike 日志单独存档(SPIKE_LOG),后来多次回查。
三、解决代码:五层落位决策链
核心函数是一个决策链:先判定"文档目录能否静默写",能则直接落 assets/;不能则进暂存仓库,引用照常写终态路径:
// entry/src/main/ets/service/ImageAssetService.ets(真实代码,节选)
// 分层落位(L1-L5 决策链):
// 文档目录可静默写(沙箱/授权/公共目录)→ 直接落 <docdir>/assets/;
// 否则(未授权单文件/未命名)→ 暂存仓库,引用仍写终态 assets/ 路径。
private async placeImage(sourceLocation, context, docUri, alt, ext): Promise<ImageInsertResult> {
const silentDir: string = this.decideSilentDocDir(context, docUri);
if (silentDir.length > 0) {
// L1/L2/L3:静默路径
const neededPerm = this.publicDirPermissionFor(silentDir);
const assetsDir = silentDir + '/' + ASSETS_DIR_NAME;
let dirReady = this.ensureAssetsDir(assetsDir);
if (!dirReady && await this.requestPublicDirPermissions(context, neededPerm)) {
dirReady = this.ensureAssetsDir(assetsDir); // 授权后重试一次
}
if (dirReady) {
const filename = this.nextAssetFilename(assetsDir, ext);
// copySourceToPath → refUrl = 'assets/<filename>'
}
}
if (refUrl.length === 0) {
// L4/L5:暂存仓库(文档目录不可静默写 / 未命名)
// 不变式:引用 basename ≡ 暂存文件 basename(同一 filename 两侧复用),
// 重启后按文件名探测重建依赖此约定
const stagingDir = await store.ensureStagingDir(context);
const filename = store.nextAssetFilename(stagingDir, ext);
this.copySourceToPath(sourceLocation, stagingDir + '/' + filename);
refUrl = ASSETS_DIR_NAME + '/' + filename; // 引用照写终态路径
store.register(refUrl, stagingDir + '/' + filename); // 暂存映射:引用 → 暂存文件
}
result.markdownRef = '';
}
三个设计决策值得展开:
- 引用永远写终态路径
,哪怕图片此刻还在暂存区。渲染层查一张"暂存映射表"就能找到真身——好处是保存迁移时源码零改写,迁移的只是文件,Markdown 一个字节不动; - 命名统一为时间戳
img-日期时间.扩展名(同毫秒撞名追加序号),放弃哈希去重——时间戳名可读可排序,重复粘贴占双份空间是可接受的代价; - 权限按目标目录单项申请:写桌面不会顺带要文档和下载的权限,拒绝其一不连坐其他。
四、解决代码:重启自愈——文件名探测重建
暂存映射表存在内存里,进程结束就没了。而"暂存的图片还没迁移就重启"完全可能发生——重启后引用还在、映射没了,图片就成了断链。修复思路来自一个被升格为显式不变式的约定:引用的文件名与暂存的文件名恒为同一字符串(代码注释里两侧声明)。既然同名,映射就可以重建——重启后遇到未命中引用,拿文件名去候选暂存目录逐个探测:
// entry/src/main/ets/service/StagingImageStore.ets(真实代码,节选)
resolveByFilename(context, filename): string {
const dirs = this.stagingCandidateDirs(context); // 公共仓库 → 持久化目录 → 沙箱兜底
for (let i = 0; i < dirs.length; i++) {
const candidatePath = dirs[i] + '/' + filename;
if (this.pathUsable(candidatePath)) {
return candidatePath; // 命中即等价于映射命中
}
}
return '';
}
配合保存时机的迁移函数(扫描文档里的暂存引用 → 静默路径直接复制落位删源;未授权路径走一次系统保存对话框批量迁移,用户取消则保留现状下次再试),形成闭环:插入即渲染,保存即落位,重启能自愈。
五、兼容与验证
两块兜底:手写引用兼容(用户手打的图片路径也支持渲染:相对路径、.//../、子目录、绝对路径、含空格/中文/百分号的文件名、尖括号与标题形式,九种形式矩阵验证);旧数据兼容(历史版本的图库目录只读兼容,保存时自动迁移改写为新格式)。
验证结果:五种处境全部按设计工作——授权目录与公共目录全程零弹窗、未授权文档保存时一次批量对话框、未命名文档另存为时迁移;九种手写引用渲染全过;构建通过。已知边界照实登记:真断链(图片文件真的被删了)静默跳过不提示;模拟器无法构造部分真机场景(非 root 无法落位测试文件),真机清单在册。
六、能力边界表
| 事项 | AI 表现 | 我的结论 |
|---|---|---|
| 权限行为预判 | 两条"铁律"均被 spike 推翻 | 权限文档 ≠ 实际行为,最小实验是最便宜的真相 |
| 五层决策链设计 | 方案清晰,分层与产品目标对齐 | "能静默的全静默,弹窗推迟到保存"是正确的体验优先级 |
| 源码零改写(终态路径 + 映射表) | 机制一次成型 | 让迁移只动文件不动源码,是整个设计的锚点 |
| 重启自愈(文件名探测) | 依赖显式不变式成立 | 把隐式约定写成显式不变式(带注释),自愈机制才敢依赖它 |
| 边界处理(真断链静默跳过) | 与既有先例一致 | 无数据可迁时提示是噪音,静默是正确的克制度 |
七、三条心得
- 权限设计要跑在文档前面:spike 实验两轮就推翻了两条想当然的"铁律",成本半天,收益是整个设计建立在真实行为上;
- "源码零改写"是编辑器类产品的通用锚点:任何自动化(迁移、重命名、整理)都不该动用户的源码字节——动文件、动映射,别动文本;
- 隐式约定要升格为显式不变式:一句"引用名 ≡ 暂存名"写进注释并被两侧代码声明依赖,自愈机制才从"碰巧能用"变成"设计保证"。
如果你在做文件系统或权限相关工作,或者想看 MarkPin 后续,关注专栏。