我在 security-scoped bookmark 上踩过的四个坑
Divecut 是一个沙盒化的 macOS 应用,它指向一个存放素材的文件夹,并且在你退出重 新启动之后,还能继续指向那个文件夹。实现这一点靠的是 security-scoped bookmark:向用户要一次文件夹权限,存下一段二 进制数据,下次启动时再把它解析出来。
这套 API 很小,我还是搞错了四次。每个坑都是同一种形状—— 一直隐形,直到某个毫不相关的东西发生变化,它才一下子全冒出来。 比起修复方法本身,这个规律才是更有用的部分。
1. 持有书签不等于拥有访问权限
启动时我只检查了书签是否存在,然后就直接往下走了。缩略图能加载,网格能填满, 一切看起来都没问题。
然后我升级了缩略图缓存的版本号。每一格瞬间都变成了文件损坏的标记。
那个书签从来没有被真正解析过。resolve() 和
startAccessingSecurityScopedResource() 从没被调用过,所以应用其实
根本没有读取原始文件的权限。缓存还热着的时候没关系,因为没有什么东西会真的去
碰源文件。让缓存失效,正好拿掉了一直藏着这个坑的那层东西。
挡在权限坑前面的缓存,只要还热着,就会一直把这个坑藏起来。
修法说不上有多漂亮:启动时老老实实走完 resolve → start accessing 这一整套流 程,并且在应用运行期间一直保持这个访问权限。
2. /var 和 /private/var 各说各话
从系统临时目录下导入一个文件夹,结果一个片段都没导进来。没有报错,没有崩溃— —就是一个空网格。
FileManager.enumerator 返回的路径里,符号链接已经被解析过了,所
以文件读出来的路径是 /private/var/…。而我拿来比较的文件夹路径是
/var/…。这两者做前缀匹配,永远都对不上。
这个坑特别讨厌的地方在于,那些看起来理所当然的规范化方法根本解决不了它。
standardizedFileURL 和 resolvingSymlinksInPath()
都不会解析 /var 这个特殊链接——如果说有什么效果,反倒是朝反方向
走,把 /private 给去掉了。真正管用的是 canonicalPath
这个资源值:
let canonical = try? standardized
.resourceValues(forKeys: [.canonicalPathKey])
.canonicalPath
修完之后还有一个陷阱:规范化必须发生在每一处被比较的路径上。 我在导入时做了规范化,但在“文件夹是否被移动”的检查里没做,于是那个对不上的问 题又直接冒了出来。
3. 过期的书签,没法从外部刷新
当用户在访达里移动了文件夹之后,书签依然能解析出来,但会带着一个 stale(过
期)标记回来,你应该重新写一个新的。我正是这么做的:检测到
isStale,再次调用 bookmarkData(),存下来。
但这其实什么都没刷新。用一个你当前并没有在访问的、重新构造出来的 URL 创建的 书签,只会再次变成过期状态——这在 Apple Developer Forums 上有人报告过,和我 遇到的情况完全吻合。
刷新必须发生在一个访问作用域内部:开始访问、写下新书签、停止访问— —这一整套都要在 resolve 返回之前完成。
4. 两次写入,一次崩溃,一个错的文件夹
为了检测“用户移动了文件夹”这件事,我把上一次成功解析出来的路径存在书签旁 边,用它自己的键。两个值,两次写入。
如果应用在这两次写入之间挂掉,你就会得到一个指向新位置的书签,和一个指向旧位 置的路径提示。下次启动时,这个组合会被读成文件夹被移动了,于是应用会 去重写那些本来根本没错的片段路径。一次时机不对的崩溃,就足以把素材库弄 坏。
修法是不再做两次写入。把两个值合并进同一段数据,存在同一个键下面。单次
UserDefaults 写入不会被撕裂,所以读取的一方要么看到完整的旧值,
要么看到完整的新值——绝不会看到一半一半的混合状态。
防住最无聊那种坑的配对写法
每一次临时访问都走同一个辅助函数,这样即使抛出异常,也不会导致作用域泄漏:
public func withAccess<T>(to url: URL, _ body: (URL) throws -> T) throws -> T {
let granted = url.startAccessingSecurityScopedResource()
defer {
if granted { url.stopAccessingSecurityScopedResource() }
}
guard granted else { throw StoreError.accessDenied }
return try body(url)
}
素材库文件夹是刻意留出的例外。它会在应用的整个生命周期内保持打开状态,所以不
会走这个辅助函数——在这里用 defer 关闭它,恰恰是错误的做法。
如果重来一次,我会先告诉自己这些
| 坑 | 是什么把它藏住的 |
|---|---|
| 书签从未被解析过 | 一个还热着的缩略图缓存 |
/var 与 /private/var | 只有临时目录才会用到这个符号链接 |
| 刷新过期书签,结果什么都没刷新 | 开发过程中没人会去移动文件夹 |
| 两次写入,一次崩溃 | 应用得刚好死在那一毫秒 |
这些坑没有一个是靠第一次就写得更仔细而发现的。全都是靠改动别的东西——一个缓 存版本号、一个测试文件夹、一次代码审查——然后看什么会跟着倒下才发现的。如果 你也在用这套 API,值得故意让缓存失效一次,就为了看看有什么东西其实一直站在虚 空之上。
Divecut 是一款面向 macOS 和 iPad 的素材浏览器:Log 片段以正确的色彩呈现,能用 大白话搜索,再以 Log 原样交给 DaVinci Resolve。 看看怎么用。