这是 Zipic 独立开发复盘系列的终篇。在搞定了 产品设计 与 独立分发 之后,开发者 十里 将带我们深入代码底层。
本文充满了硬核、实用的 macOS 开发经验,从 SwiftUI 的组件适配到 Core Graphics 的底层应用,从 Raycast 扩展的集成到 PDF 压缩的实现,不仅解决了性能瓶颈,更让原生体验达到了极致。
嗨,大家好,我是十里👋!
欢迎来到 Zipic 复盘系列的最后一篇。如果说前两篇文章是在聊“做什么”和“怎么卖”,那么今天这篇文章就是纯粹的聊“怎么写”。
macOS 开发与 iOS 开发虽然共享很多框架,但在文件系统、窗口管理和底层图形处理上有着截然不同的挑战。Zipic 追求极致的轻量与性能,这意味着不能简单地依赖高级封装,很多时候必须下潜到 Core Graphics 甚至内核事件层面去寻找答案。
以下是我在开发过程中遇到的一些具体技术挑战,以及最终的解决方案。
UI 细节中的技术实现
macOS 26 中隐藏主窗口标题栏

Zipic 的界面设计追求简洁,不需要传统的标题栏。在早期版本中,用 .windowStyle(.hiddenTitleBar) 就能完美隐藏标题栏,一切正常。
但升级到 macOS 26 后,问题来了。标题栏确实“隐藏”了,但留下了一块白色的背板区域,像是标题栏的“幽灵”还在那里占位,整个窗口顶部多了一条不协调的留白。更麻烦的是,这个问题在 Xcode 预览模式下看不出来,预览里一切正常,真机运行才会发现,意味着每次调试都要编译运行。
查阅资料后发现,macOS 26 引入了新的窗口背景系统。原来的 .hiddenTitleBar 只是隐藏了标题栏的 UI 元素,但没有处理底层的容器背景。新的解决方案是使用 .containerBackground(.clear, for: .window) 修饰符:
WindowGroup {
ContentView()
}
.windowStyle(.hiddenTitleBar)
.containerBackground(.clear, for: .window)
这个修饰符告诉系统窗口的容器背景应该是透明的,配合 .hiddenTitleBar 终于实现了完全无标题栏的效果。
具体适配细节可以参考:macOS 开发 - macOS 26 中隐藏主窗口标题栏
踩坑经验:如果应用使用了 NavigationSplitView,还需要额外处理侧边栏的标题。默认情况下侧边栏会显示一个 inline 样式的导航标题,也需要隐藏:
NavigationSplitView {
SidebarView()
.toolbar(removing: .title)
} detail: {
DetailView()
}
还有一个细节是窗口拖拽——隐藏标题栏后用户怎么拖动窗口?我的做法是通过 NSWindow 的 isMovableByWindowBackground 属性,或者在特定区域添加自定义的拖拽手势,既保持简洁界面又不影响用户操作。另外,.containerBackground 是 macOS 15+ 的 API,如果需要兼容更早的系统版本要做好条件判断。
文件大小显示与 Finder 保持一致

有用户反馈:“Zipic 显示的文件大小和 Finder 里看到的不一样,是不是压缩有问题?”
这让我很困惑,文件大小就是用 FileManager 获取的 fileSize 属性,怎么会不对?仔细对比后发现差异确实存在,比如一个文件 Finder 显示 1.26 MB,Zipic 显示 1.2 MB。虽然差别不大,但对于一个“精确压缩”的工具来说,这种不一致会让用户产生疑虑。
问题出在“文件大小”这个概念本身。文件系统中大小有两种:
- 逻辑大小(Logical Size):文件实际包含的数据量
- 物理大小(Physical Size):文件在磁盘上占用的空间
Finder 默认显示的是逻辑大小,而且使用的是 1000 进制(1 KB = 1000 bytes),而不是程序员习惯的 1024 进制。要获取与 Finder 一致的文件大小,需要用 URL 的 resourceValues API:
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey])
let fileSize = resourceValues.fileSize ?? 0
格式化显示时,要用 ByteCountFormatter 并指定正确的选项:
let formatter = ByteCountFormatter()
formatter.countStyle = .file // 使用 Finder 的计算方式
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
let displaySize = formatter.string(fromByteCount: Int64(fileSize))
关键是 .countStyle = .file,这会让格式化器使用 macOS 文件系统的标准方式来显示大小。
详细实现可以参考:macOS 开发 - 获取与 Finder 一致的文件大小
踩坑经验:有几个特殊情况需要注意:
- 稀疏文件:某些文件的逻辑大小和物理大小差异很大,比如虚拟机磁盘文件逻辑上可能有 64GB,但实际只占用几个 GB。
- APFS 透明压缩:APFS 文件系统支持透明压缩,一个文件可能逻辑上是 10MB,但因为系统自动压缩实际只占 3MB,这种情况下“节省空间”的计算会变得复杂。
- 符号链接:如果文件是符号链接,获取的可能是链接本身的大小而不是目标文件的大小,需要用
.resolvingSymlinksInPath()先解析真实路径。
我的处理策略是统一使用逻辑大小作为显示标准,和 Finder 保持一致。修复后再也没收到类似反馈。
没有设计稿,直接写代码
Zipic 的 UI 有个“秘密”:从来没有画过设计稿。
不是因为懒,而是发现对于工具类应用,脑子里有清晰的设计原则比画设计稿更高效。Zipic 的 UI 严格遵守《写给大家的设计书》里的四个基本原则:亲密性、对齐、重复、对比。这四个原则简单易懂,却是让 UI 提升一个台阶的最简单可行的方式。

- 亲密性(Proximity):相关的元素放在一起。Zipic 的压缩列表里,每个文件项的缩略图、文件名、大小、压缩率紧挨着,形成一个视觉单元;而不同文件之间有明显的间距。用户扫一眼就能分清哪些信息属于同一张图片。设置面板也是,按功能分组——输出设置放一块,质量设置放一块,不会把“输出路径”和“压缩质量”混在一起。

- 对齐(Alignment):所有元素都要有视觉上的连接。Zipic 的侧边栏、列表视图、详情面板,相同元素内所有文字都有对齐。看起来是小事,但如果对齐乱了,整个界面会显得“业余”。SwiftUI 的
alignment参数用得很多,.leading、.trailing、.center要根据内容类型选择。

- 重复(Repetition):统一的视觉元素贯穿始终。Zipic 里的圆角半径是统一的,所有可点击的文字都用同一种颜色。图标风格也统一——要么都用 SF Symbols,要么都用自定义图标,不混搭。这种一致性让用户觉得“这是一个完整的产品”,而不是“拼凑出来的”。

- 对比(Contrast):重要的东西要突出。压缩率是用户最关心的数据,所以用蓝色渐变标签显示;文件名次要,用常规字体;原始大小更次要,用浅灰色。主次分明,用户不用思考就知道该点哪个。
这四个原则听起来简单,但真正用好需要不断调整。我的做法是:写完代码先跑起来看效果,觉得哪里“不对劲”就用这四个原则去检查。是不是相关元素没有靠近?是不是对齐出了问题?是不是重复的元素不够统一?是不是该突出的没突出?
没有设计稿的好处是迭代快——改几行代码就能看到效果,不用先改设计稿再改代码。当然这种方式有个前提:脑子里要有清晰的设计原则,不然很容易改着改着就乱了。
核心功能的技术实现
接下来分享几个核心功能的实现细节,这些都是在 macOS 平台上比较有意思的技术点。
文件夹监控自动压缩

文件夹监控是 Zipic 的特色功能:用户指定一个文件夹,Zipic 实时监控它的变化,一旦有新图片添加进来就自动压缩。需求看着直白,实现时遇到了不少问题:监控机制怎么选、事件爆发怎么处理、输出文件触发无限循环怎么办……
首先是监控机制的选择。最直观的方式是轮询——每隔几秒扫描一次目录看有没有新文件。但这种方式太耗资源了,尤其是当用户监控的文件夹里有成千上万个文件时。最终选择了 macOS 提供的 DispatchSource.FileSystemEvent,这是基于内核级 kqueue 的高效监控机制,只在文件系统真正发生变化时才会触发,CPU 占用极低。
// 使用 O_EVTONLY 标志打开目录(只监听事件,不阻止卸载)
fileDescriptor = open(url.path, O_EVTONLY)
// 创建 DispatchSource 监控文件系统对象
source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: [.write, .extend, .delete, .rename],
queue: configuration.queue
)
接下来遇到的问题是事件爆发。用户一次性拖入几百张图片,系统会在瞬间触发几百个事件。如果每个事件都立即处理,不仅效率低,还可能导致界面卡顿。解决方案是实现防抖机制(Debounce):当连续收到多个事件时,等待一小段时间(默认 0.5 秒),把这段时间内的事件合并成一次处理。这样用户拖入 100 张图片,最终只会触发一次压缩任务。
最棘手的问题是无限循环。想象一下:用户监控文件夹 A,Zipic 检测到新图片 photo.jpg,压缩后生成 photo_compressed.jpg。但这个输出文件也在文件夹 A 里,于是又触发了监控事件……如此往复,无限循环。
为此我设计了预测性忽略机制。当检测到输入文件时,系统会预测输出文件的路径,提前加入忽略列表:
let predictor = FileTransformPredictor.imageCompression(suffix: "_compressed")
let predictedOutputs = predictor.predictOutputFiles(for: inputURL)
ignoreList.addPredictiveIgnore(predictedOutputs)
过滤系统也很重要。用户可能只想压缩特定类型的图片,或者只处理大于某个尺寸的文件。我采用了谓词模式来实现灵活的条件组合:
// 只监控图片文件,大于 1KB,1 小时内修改的
let filter = FileFilter.imageFiles
.and(.fileSize(1024...))
.and(.modifiedWithin(3600))
还有一些细节值得注意。监控深度要根据实际场景设置——对于大多数用户,监控一两层子目录就够了。如果设置成无限深度,遇到复杂的目录结构(比如 node_modules),会创建大量 watcher,占用过多系统资源。另外,新创建的子目录也需要动态加入监控,这要求系统在检测到目录变化时,自动检查是否有新的子目录并为它们创建 watcher。
这套方案经过反复打磨后,后来我把核心部分开源成了 FSWatcher 项目,希望能帮到有类似需求的开发者。
PDF 压缩:利用 macOS 原生能力

PDF 作为日常文档中最常见的格式,很多时候里面就是塞满了图片。支持 PDF 压缩是用户呼声很高的需求。
PDF 压缩和普通图片压缩完全是两码事。图片压缩相对简单——读取像素数据,用算法重新编码,写入新文件。但 PDF 是复杂的容器格式,里面可能包含文本、矢量图形、嵌入字体、书签、表单……当然还有图片。关键问题是:如何在不破坏 PDF 结构的前提下,只对其中的图片进行压缩?
最初考虑过几种方案:Ghostscript 功能强大但体积也大,需要处理外部依赖的分发问题;ImageMagick 对 PDF 结构的保留不够好;自己解析 PDF 格式手动提取和重压缩图片——这条路想想就知道是个无底洞。
最终选择了 macOS 原生的 Quartz Filter 技术,说实话有点相见恨晚。它就藏在系统里专门干这个事,系统自带的“预览”应用导出 PDF 时的“减小文件大小”选项用的就是这个。工作原理很优雅:在渲染 PDF 页面时自动对其中的位图图像进行 JPEG 重压缩,而文本、矢量图形、字体这些保持原样不动。
这是 macOS 平台独有的优势——Core Graphics 框架有硬件加速支持,处理速度很快,而且不需要打包任何外部依赖,应用体积不会膨胀。
核心思路是动态生成自定义的 .qfilter 配置文件定义压缩参数,然后通过 Core Graphics 框架应用这个滤镜来渲染 PDF。整个流程:根据用户选择的压缩等级生成对应的 Quartz Filter 配置文件 → 读取源 PDF → 创建新的 PDF 上下文并应用 Filter → 逐页渲染(这一步 Filter 会自动处理图像压缩)→ 输出压缩后的 PDF。
关键代码(Quartz Filter 文件生成):
private static func generateQFilter(at url: URL, compressionQuality: Double, level: Int) {
let xmlContent = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
<key>FilterData</key>
<dict>
<key>ColorSettings</key>
<dict>
<key>ImageSettings</key>
<dict>
<key>Compression Quality</key>
<real>\(compressionQuality)</real> <!-- 0.0-1.0 -->
<key>ImageCompression</key>
<string>ImageJPEGCompress</string> <!-- JPEG 压缩 -->
<key>ImageScaleSettings</key>
<dict>
<key>ImageScaleFactor</key>
<real>1.0</real> <!-- 保持原尺寸 -->
</dict>
</dict>
</dict>
</dict>
<key>FilterType</key>
<integer>1</integer>
<key>Name</key>
<string>Compress PDF</string>
</dict>
</plist>
"""
try xmlContent.write(to: url, atomically: true, encoding: .utf8)
}
压缩等级的设计上,我定义了 6 个级别,对应不同的 JPEG 质量参数(0.9 到 0.2)。值得注意的是,JPEG 压缩到 0.2 这个级别图片会出现明显的压缩痕迹,对于需要打印或展示的文档建议用较高的质量级别,如果只是用于网络传输或归档可以激进一些。
关键代码(PDF 压缩核心逻辑):
static func pdf(at sourceURL: URL, to destinationURL: URL, compressionLevel: Double) -> CommandResult {
// 1. 加载 Quartz Filter
guard let filter = QuartzFilterManager.filterForCompressionLevel(compressionLevel) else {
return CommandResult(output: "Failed to load filter", error: .exit, status: -1)
}
// 2. 读取源 PDF
guard let sourcePDF = PDFDocument(url: sourceURL) else {
return CommandResult(output: "Failed to load PDF", error: .exit, status: -1)
}
// 3. 创建数据容器和 PDF 上下文
let mutableData = NSMutableData()
guard let consumer = CGDataConsumer(data: mutableData),
let firstPage = sourcePDF.page(at: 0) else { return /* error */ }
var mediaBox = firstPage.bounds(for: .mediaBox)
guard let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
return /* error */
}
// 4. 应用 Filter 到上下文(关键步骤)
guard filter.apply(to: pdfContext) else {
return CommandResult(output: "Failed to apply filter", error: .exit, status: -1)
}
// 5. 渲染所有页面
return renderPDFPages(sourcePDF: sourcePDF, context: pdfContext,
data: mutableData, destinationURL: destinationURL)
}
关键代码(页面渲染实现):
private static func renderPDFPages(
sourcePDF: PDFDocument,
context: CGContext,
data: NSMutableData,
destinationURL: URL
) -> CommandResult {
// 逐页渲染(Filter 自动处理图像压缩)
for pageIndex in 0..<sourcePDF.pageCount {
guard let page = sourcePDF.page(at: pageIndex) else { continue }
var pageRect = page.bounds(for: .mediaBox)
context.beginPage(mediaBox: &pageRect)
page.draw(with: .mediaBox, to: context) // 绘制时 Filter 生效
context.endPage()
}
context.closePDF()
// 写入文件
try data.write(to: destinationURL, options: .atomic)
return CommandResult(output: "Success", error: .exit, status: 0)
}
踩坑经验:动态生成的 .qfilter 文件需要存放在合适的位置(我选择了 ~/Library/zipic/filters/),要处理好文件不存在或损坏的情况,必要时重新生成。
另外要注意的是,Quartz Filter 只压缩 PDF 中的位图图像,如果一个 PDF 主要是文字和矢量图形,压缩后体积可能变化不大——这一点需要在产品层面向用户说明,避免“为什么压了半天没变小”的困惑。好消息是 PDF 中的元数据(书签、链接、表单等)在这个方案下都能完整保留,这是很多第三方方案做不到的。
缩略图生成优化

Zipic 的列表视图需要展示大量图片缩略图。最初的实现很朴素:用 AsyncImage 直接加载原图,让系统自动缩放显示。小图没问题,但遇到大图就明显卡顿,内存也跟着飙升。
第一次优化:预生成缩略图缓存
思路很直接——既然每次都要缩放,不如预先生成好缩略图存到磁盘,下次直接加载小图。早期实现用的是 NSImage 的 lockFocus/unlockFocus 方法:
// 早期版本(已废弃)
func thumbnail(with width: CGFloat) -> NSImage {
let thumbnailImage = NSImage(size: thumbnailSize)
thumbnailImage.lockFocus()
self.draw(in: thumbnailRect, from: .zero, operation: .sourceOver, fraction: 1.0)
thumbnailImage.unlockFocus()
return thumbnailImage
}
问题是:这种方式需要先把原图完整解码到内存,再缩放绘制。一张 8000×6000 的图片解码后占用约 192MB 内存,批量处理时内存轻松飙到几个 GB。而且 lockFocus 已被 Apple 标记为废弃 API,在后台线程调用还可能触发优先级倒置。
第二次优化:CGContext 手动绘制
为了解决 lockFocus 的问题,改用 CGContext 手动创建位图上下文:
// 改进版本(仍有问题)
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return self
}
guard let context = CGContext(...) else { return self }
context.draw(cgImage, in: CGRect(origin: .zero, size: thumbnailSize))
解决了线程安全问题,但本质没变——依然要先完整解码原图才能获取 CGImage,内存问题还在。
最终方案:ImageIO 下采样
后来发现 ImageIO 框架提供了 CGImageSourceCreateThumbnailAtIndex 这个 API,可以直接从图片文件生成指定尺寸的缩略图,内部采用渐进式下采样,根本不需要完整解码原图。这才是正解。
func savePNGThumbnail(for imageURL: URL, maxPixelSize: Int = 192) -> URL? {
return autoreleasepool {
// 创建图像源(不缓存原图)
let sourceOptions: CFDictionary = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(imageURL as CFURL, sourceOptions) else {
return nil
}
// 配置缩略图选项
let thumbnailOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true, // 自动应用 EXIF 方向
kCGImageSourceShouldCacheImmediately: true, // 立即解码,避免优先级倒置
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize // 最大边 192px
]
// 直接生成缩略图(内部下采样,不完整解码)
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else {
return nil
}
// 写入 PNG 文件
let destURL = thumbnailsDir.appendingPathComponent(fileComponent)
.deletingPathExtension().appendingPathExtension("png")
guard let destination = CGImageDestinationCreateWithURL(
destURL as CFURL, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return destURL
}
}
效果对比:同样处理 8000×6000 的 JPEG,完整解码约 192MB,下采样生成 192px 缩略图约 0.15MB——内存节省近千倍。
关键参数说明:
kCGImageSourceShouldCache: false:创建图像源时禁用缓存,避免原图数据驻留内存kCGImageSourceShouldCacheImmediately: true:立即在当前线程解码,防止被调度到低优先级后台线程导致优先级倒置kCGImageSourceCreateThumbnailWithTransform: true:自动应用 EXIF 方向信息,手机拍的竖屏照片不会“横着”显示kCGImageSourceThumbnailMaxPixelSize:指定最大边长,ImageIO 内部会按需下采样
踩坑经验:
- 内存释放:批量处理时用
autoreleasepool包裹循环体,确保每张图处理完立即释放临时对象,避免内存累积 - 同名文件冲突:早期缩略图只用文件名存储,不同目录下的
photo.jpg会互相覆盖。解决方案是缓存目录保留相对路径结构,比如a/photo.jpg和b/photo.jpg的缩略图分别存到thumbnails/a/photo.png和thumbnails/b/photo.png
这个优化的核心原则是:对于只需展示缩略图的场景,永远不要完整解码原图。ImageIO 的下采样是 Apple 官方推荐的最佳实践。
设备指纹稳定性优化

一度收到用户反馈“没有换电脑却需要重新激活”——排查后发现旧版指纹算法包含了会变化的主机名,网络环境变化或系统更新都可能让 hostname 改变,导致指纹漂移从而触发误判。
解决方案是设备指纹只依赖稳定的硬件标识,移除任何可变的软标识,最终仅保留主板序列号与以太网 MAC 地址参与计算:
func generateStable() -> String {
var components: [String] = []
if let boardSerial = getBoardSerial() {
components.append(boardSerial) // 主板序列号
}
if let macAddress = getMACAddress() {
components.append(macAddress) // 以太网 MAC 地址
}
let joined = components.joined(separator: "|")
let data = Data(joined.utf8)
return data.sha256().map { String(format: "%02hhx", $0) }.joined()
}
踩坑经验:
- 静默迁移策略:验证失败时自动回退用旧指纹再试,若旧指纹有效则后台注销旧设备并以新指纹重新激活,全流程用户无感知
- 网络错误容错:仅在明确的业务错误码下才注销授权,对超时、网络抖动等异常只记录日志与重试,不改变本地授权状态
批量压缩并发优化

当用户一次性拖入几百张图片时,如何高效处理是个技术挑战。最初的串行方案——一张一张压缩——显然太慢了,但简单地开满并发也会带来问题:系统卡顿、内存飙升、任务调度混乱。
最终方案是 OperationQueue + DispatchGroup 的组合,配合 QueueManager 实现双队列负载均衡调度。
为什么是双队列?
单队列的问题很明显:用户先拖入 500 张大图,队列被占满;紧接着又拖入 3 张小图想快速处理,却要排在 500 张后面等待。双队列设计让新任务可以分配到负载较轻的队列,小批量任务不会被大批量任务阻塞。
关键代码(QueueManager 负载均衡分配):
class QueueManager {
static let shared = QueueManager(num: 2) // 双队列
var queues = [OperationQueue]()
var counts = [Int]() // 记录每个队列的任务数
init(num: Int) {
for _ in 1...num {
let queue = OperationQueue()
queue.qualityOfService = .userInitiated
queue.maxConcurrentOperationCount = 8 // 可配置
queues.append(queue)
counts.append(0)
}
}
/// 负载均衡:选择任务数最少的队列
func allocate(count: Int) -> OperationQueue {
var index = 0
if let minCount = counts.min(),
let indexOfMinValue = counts.firstIndex(of: minCount) {
index = indexOfMinValue
}
counts[index] += count
return queues[index]
}
}
关键代码(批量压缩流程):
static func compress(urls: [URL], by compressedImages: CompressedImages) {
let group = DispatchGroup()
// 1. 获取负载最轻的队列
let operationQueue = QueueManager.shared.allocate(count: urls.count)
// 2. 为每张图片创建 Operation
for url in urls {
let operation = BlockOperation {
// 生成缩略图、执行压缩、更新 UI
}
operation.completionBlock = {
group.leave()
}
group.enter()
operationQueue.addOperation(operation)
}
// 3. 所有任务完成后发送通知
group.notify(queue: .main) {
// 显示完成通知
}
}
为什么选择 OperationQueue 而非 GCD?
OperationQueue 相比原生 GCD 有几个关键优势:支持任务取消(用户清空列表时需要)、可以设置最大并发数、能查询任务状态。这些特性对于需要精细控制的批量处理场景非常重要。
踩坑经验:
- 线程安全:多个 Operation 会并发更新进度统计,需要用
DispatchSemaphore保护共享状态避免数据竞争 - QoS 设置:必须设置
queue.qualityOfService = .userInitiated,否则系统会有优先级警告且任务可能被降级执行 - 并发数选择:默认 8 是经验值。压缩是 CPU 密集型操作,过高的并发数会导致上下文切换开销增大,过低则 CPU 利用率不足。
- UI 更新:Operation 内部的 UI 更新必须 dispatch 到主线程,否则会有线程安全问题和警告
- 多批次进度追踪:用户可能连续拖入多批图片,每批任务需要独立追踪进度,总进度是所有批次的加权平均。我设计了一个
TaskStack来管理这种场景
开放生态构建:URL Scheme 与 Raycast 扩展

工具类产品要真正融入用户的工作流,光靠自己的界面是不够的。用户可能在用 Raycast、Alfred、快捷指令,甚至自己写脚本——如果 Zipic 能被这些工具调用,使用场景会宽广很多。
macOS 上跨应用通信最通用的方式是 URL Scheme。在 Info.plist 中注册自定义 scheme 后,任何应用都可以通过 open "zipic://..." 唤起 Zipic 并执行任务。
我的设计是:zipic://compress?url=路径1&url=路径2&level=3&format=webp,支持传入多个文件路径和压缩参数。
// 注册 URL Scheme 后,在 App 入口处理传入的 URL
private func handle(_ url: URL) {
guard url.scheme == "zipic", url.host == "compress" else { return }
// 解析 URL 参数
guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems else { return }
// 解析文件路径参数 (支持多个 url 参数)
let urls = queryItems.compactMap { item -> URL? in
guard item.name == "url", let path = item.value?.removingPercentEncoding else { return nil }
return URL(fileURLWithPath: path)
}
// 解析压缩选项(level、format 等)并执行压缩
Common.compress(urls: urls, by: CompressedImages.shared, with: parseOptions(from: queryItems))
}
Raycast 扩展实践
基于这套 URL Scheme,我开发了 Zipic 的 Raycast 扩展,目前已有 2000+ 安装量。
用户的典型工作流变成了:在 Finder 选中图片 → 按快捷键呼出 Raycast → 输入 compress 回车。如果为命令设置了全局热键,甚至可以做到“选中图片 -> 按快捷键 -> 完成压缩”,整个过程不到 1 秒。
// Raycast 扩展核心逻辑(TypeScript)
export default async function Command() {
const selectedItems = await getSelectedFinderItems();
const paths = selectedItems.map(item => item.path);
// 构建 URL Scheme 调用
const data = JSON.stringify({ urls: paths });
const url = `zipic://compress?data=${encodeURIComponent(data)}`;
await open(url); // 调用 Zipic
}
扩展做的事情很简单:获取 Finder 选中的文件路径,拼成符合 Zipic 规范的 URL,然后 open。Zipic 端收到请求后解析路径执行压缩,完成后发送系统通知。
踩坑经验:
- 路径编码:文件路径可能包含空格、中文等特殊字符,拼接 URL 时必须进行 Percent Encoding,否则 App 端解析会出错(Swift 的
URLComponents会辅助处理,但发起端要规范)。 - 安全校验:App 收到路径后要验证文件是否存在、是否是支持的图片格式,并且要过滤掉
.app或.bundle等特殊目录,防止被恶意调用或误操作。
除了 URL Scheme,Zipic 也支持 macOS 13+ 的 App Intents,可以直接在“快捷指令”中调用,未来甚至能被 Apple Intelligence 识别。但 URL Scheme 目前的优势是兼容性好、集成简单,对于 Raycast/Alfred 这类效率工具依然是最佳选择。
结语与展望
回顾 Zipic 的开发过程,从 UI 细节的打磨到底层性能的优化,每一个“看起来简单”的功能背后,都有着不为人知的技术取舍。
我始终认为,技术是服务于产品的。无论是选择原生 Swift 开发,还是死磕 ImageIO 的内存优化,最终目的都是为了给用户提供一个“打开即用、用完即走”的流畅工具。
Zipic 能做出来,也离不开开源社区的贡献。Sparkle 解决了自动更新,Keygen 解决了授权,libwebp 提供了核心压缩能力。作为回馈,我也将文中提到的文件夹监控核心方案整理并开源成了 FSWatcher,希望能帮到有类似需求的开发者。
最后,感谢每一位阅读这系列文章的朋友。如果你对 macOS 原生开发感兴趣,或者想体验一下文中提到的这些技术落地的实际效果,欢迎访问 Zipic 官网 下载试用。
希望能通过这组文章,鼓励更多开发者尝试在 macOS 平台上创造属于自己的独立产品。
我们江湖再见!