macOS 对文件类型的判断并不依赖扩展名,而是依赖 Uniform Type Identifiers(简称 UTI)。扩展名只是 UTI 的一个标签,真正的类型信息由 Info.plist 中的类型声明驱动。想把自定义格式稳定地用于打开、保存、拖拽和 Spotlight,需要同时完成三件事:定义 UTI、声明导入或导出、把 UTI 关联到 App 文档类型。

为什么扩展名不等于文件类型
在 Windows 或 Linux 上,文件扩展名常常直接决定文件类型;macOS 的 Launch Services 则采用 UTI 这一抽象层。一个 UTI 可以同时绑定多个扩展名和 MIME 类型,也可以不绑定任何扩展名。系统真正用于选择打开应用、识别文件是否为图片或文档的,是这个 UTI 标识符,而不是扩展名字符串。
UTI 采用反向域名命名,例如 com.example.myapp.doc。它还有一个继承体系,通过 UTTypeConformsTo 声明自己遵循哪些父类型。最常见的父类型包括 public.data、public.content、public.text、public.image 等。继承关系会让你的格式自动获得系统对父类型已有的处理能力,比如可以被快速预览、被 Spotlight 索引,或者在打开面板中出现在对应分类下。
如果只把扩展名写进 CFBundleDocumentTypes,却没有提前声明 UTI,系统会在运行时尝试猜测类型,结果往往变成 public.data 或动态 UTI。这会导致双击文件时应用无法被可靠唤起,扩展名也可能被其他程序抢走。因此第一步永远是建立 UTI 声明。
用 UTExportedTypeDeclarations 定义自定义 UTI
导出自定义类型使用 Info.plist 中的 UTExportedTypeDeclarations 数组。这个数组里的每一个字典描述一种你的 App 拥有且对外发布的文件格式。字典中必须包含 UTTypeIdentifier 和 UTTypeTagSpecification,推荐同时加上 UTTypeDescription 与 UTTypeConformsTo。
下面是一个完整的 plist 片段,定义了一个扩展名为 exdoc 的示例项目文档类型。注意 <key> 与 <string> 的层级顺序,写错一层系统读取时可能直接忽略声明。
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.example.myapp.doc</string>
<key>UTTypeDescription</key>
<string>Example Project Document</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>exdoc</string>
</array>
<key>public.mime-type</key>
<string>application/x-example-doc</string>
</dict>
</dict>
</array>
UTTypeTagSpecification 是真正把 UTI 和扩展名、MIME 类型连接起来的地方。一个 UTI 可以声明多个扩展名,但通常不建议让同一个扩展名指向两个不同的 UTI,系统会选择一个优先级高的声明,可能不是你的应用。MIME 类型的作用体现在网络下载、邮件附件和浏览器场景中,如果文件会通过 HTTP 传输,合理填写 public.mime-type 可以避免服务端把文件识别成 application/octet-stream。
UTTypeConformsTo 的层级要谨慎选择。自定义文档通常遵循 public.data 已经够用;如果内容是 JSON 或纯文本,可以遵循 public.text 或 public.json。继承 public.content 可以让文件获得“内容类”系统能力,但过高层级可能让系统错误地认为它属于可预览的内容包。对于完全私有的二进制数据,只继承 public.data 反而更稳妥。
声明 UTImportedTypeDeclarations 处理第三方类型
导入类型用于说明你的 App 能读取某种格式,但该格式不属于你的应用。典型例子是 PDF:你不想拥有 com.adobe.pdf,但希望打开面板和拖拽操作把 PDF 路由给你的应用。这时应在 UTImportedTypeDeclarations 中声明该类型,而不是导出它。
导入声明与导出声明的字段完全一致,区别只在于语义。系统会把多个应用对同一导入类型的声明合并,而导出自定义类型则通常只由一个主应用声明。如果两个应用同时导出同一个 UTI,后安装者可能覆盖前者的标签,造成扩展名关联混乱。
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.adobe.pdf</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.composite-content</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>pdf</string>
</array>
<key>public.mime-type</key>
<string>application/pdf</string>
</dict>
</dict>
</array>
导入第三方类型时,UTTypeIdentifier 必须是该类型公认的系统标识符。常见格式如 com.adobe.pdf、public.png、public.jpeg,这些在系统内部已经存在。你声明的标签只是帮助系统在缺少注册信息时进行补充,不会覆盖系统已有声明。对于纯自定义格式,不能放到导入区,必须放到导出区,否则系统不会认为你的 App 拥有该格式。
把自定义 UTI 关联到 App 的文档类型
单独写 UTExportedTypeDeclarations 不足以让 App 出现在“打开方式”菜单中。还需要在 CFBundleDocumentTypes 中声明这个 UTI 是 App 可处理的文档类型,并通过 LSItemContentTypes 指向你之前定义的标识符。
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Example Project Document</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.example.myapp.doc</string>
</array>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
LSHandlerRank 控制打开冲突时的优先级。自己的文档类型可以设置为 Owner,表示这是该格式的原生应用。对于导入的 PDF 这类第三方类型,通常用 Alternate 或 Viewer,避免抢走系统默认阅读器的位置。CFBundleTypeRole 可以是 Editor、Viewer 或 None,分别对应编辑、查看和不直接处理。
关联完成后,重新构建应用并触发 Launch Services 重新扫描。开发阶段可以用 lsregister 命令强制注册,也可以用命令 mdls -name kMDItemContentType 文件路径 查看系统实际识别的 UTI 是否与你声明的标识符一致。只有在识别正确后,双击文件才会稳定路由到你的应用。
使用 UniformTypeIdentifiers 框架与现代 API
macOS 11 之后,Apple 提供了 UniformTypeIdentifiers 框架,允许在 Swift 代码中直接创建和引用 UTI,而不必完全依赖 plist 字符串。下面代码展示了如何在代码中导出一个自定义类型,并与 .data 建立继承关系。
import UniformTypeIdentifiers
extension UTType {
static var exampleDoc: UTType {
UTType(exportedAs: "com.example.myapp.doc", conformingTo: .data)
}
}
这种方式适合需要在代码中做读写判断、导出面板过滤或拖拽类型校验的场景。不过 Info.plist 中的声明仍然是系统识别文件类型的基础,代码中的 UTType(exportedAs:) 只是创建了类型描述对象,不能替代 plist 里的 UTExportedTypeDeclarations。如果 App 需要兼容较早的 macOS 版本,也应该继续使用 plist 声明方式。
调试 UTI 问题时,可以先检查 mdls 输出的 kMDItemContentType 与 kMDItemContentTypeTree。如果内容类型显示为 dyn.xxxxxx,说明系统没有找到你的声明,而是根据扩展名临时生成了动态 UTI。这时重点检查扩展名是否拼写正确、plist 数组层级是否符合 Xcode 的显示结构,以及构建后的 App 是否包含最终的 Info.plist 修改。

