鸿蒙权限模型实战:Markdown 图片跟随文档的五层落位设计(spike 实验推翻两条铁律)

4 阅读7分钟

鸿蒙权限模型实战:Markdown 图片跟随文档的五层落位设计(spike 实验推翻两条铁律)

Markdown 文档里插入图片,有个经典难题:图片文件放哪。放全局图库,文档拷给别人图片就断链;放文档旁的 assets/ 文件夹,文档整个文件夹拷走,图片随行可用。MarkPin 选择了后者,但"往文档旁边写文件"这件事,在鸿蒙的权限模型下远比想象复杂——同一个操作,在不同位置的文档上,权限行为完全不同。这篇讲我们的五层落位设计与两次被 spike 实验推翻的"铁律"。

一、问题定义:一个操作,五种权限处境

粘贴/拖入一张图片,要把它写到"当前文档所在目录的 assets 文件夹"。但文档可能在这些位置:

  1. 应用沙箱内(新建未保存的文档);
  2. 已授权的工作区文件夹(用户通过"打开文件夹"授过权);
  3. 系统公共目录(文档/下载/桌面);
  4. 单独打开的文件(只有这一个文件的授权,父目录不可写);
  5. 未命名文档(压根没有目录)。

五种处境对应五种权限能力:有的能直接静默写,有的要先申请权限,有的完全没有写权限。如果每种处境都弹一次窗、问一次用户,体验就碎了。设计目标因此定为两条:能静默的全部静默;不能静默的,把弹窗推迟到"保存"这个用户本来就要做的动作里。

二、分析方法论: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 = '![' + alt + '](' + refUrl + ')';
}

三个设计决策值得展开:

  • 引用永远写终态路径 ![alt](assets/img-xxx.png),哪怕图片此刻还在暂存区。渲染层查一张"暂存映射表"就能找到真身——好处是保存迁移时源码零改写,迁移的只是文件,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 推翻权限文档 ≠ 实际行为,最小实验是最便宜的真相
五层决策链设计方案清晰,分层与产品目标对齐"能静默的全静默,弹窗推迟到保存"是正确的体验优先级
源码零改写(终态路径 + 映射表)机制一次成型让迁移只动文件不动源码,是整个设计的锚点
重启自愈(文件名探测)依赖显式不变式成立把隐式约定写成显式不变式(带注释),自愈机制才敢依赖它
边界处理(真断链静默跳过)与既有先例一致无数据可迁时提示是噪音,静默是正确的克制度

七、三条心得

  1. 权限设计要跑在文档前面:spike 实验两轮就推翻了两条想当然的"铁律",成本半天,收益是整个设计建立在真实行为上;
  2. "源码零改写"是编辑器类产品的通用锚点:任何自动化(迁移、重命名、整理)都不该动用户的源码字节——动文件、动映射,别动文本;
  3. 隐式约定要升格为显式不变式:一句"引用名 ≡ 暂存名"写进注释并被两侧代码声明依赖,自愈机制才从"碰巧能用"变成"设计保证"。

如果你在做文件系统或权限相关工作,或者想看 MarkPin 后续,关注专栏。