这是 Zipic 独立开发复盘系列的终篇。在搞定了 产品设计独立分发 之后,开发者 十里 将带我们深入代码底层。

本文充满了硬核、实用的 macOS 开发经验,从 SwiftUI 的组件适配到 Core Graphics 的底层应用,从 Raycast 扩展的集成到 PDF 压缩的实现,不仅解决了性能瓶颈,更让原生体验达到了极致。

嗨,大家好,我是十里👋!

欢迎来到 Zipic 复盘系列的最后一篇。如果说前两篇文章是在聊“做什么”和“怎么卖”,那么今天这篇文章就是纯粹的聊“怎么写”。

macOS 开发与 iOS 开发虽然共享很多框架,但在文件系统、窗口管理和底层图形处理上有着截然不同的挑战。Zipic 追求极致的轻量与性能,这意味着不能简单地依赖高级封装,很多时候必须下潜到 Core Graphics 甚至内核事件层面去寻找答案。

以下是我在开发过程中遇到的一些具体技术挑战,以及最终的解决方案。

UI 细节中的技术实现

macOS 26 中隐藏主窗口标题栏

DMG Canvas Package

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()
}

还有一个细节是窗口拖拽——隐藏标题栏后用户怎么拖动窗口?我的做法是通过 NSWindowisMovableByWindowBackground 属性,或者在特定区域添加自定义的拖拽手势,既保持简洁界面又不影响用户操作。另外,.containerBackground 是 macOS 15+ 的 API,如果需要兼容更早的系统版本要做好条件判断。

文件大小显示与 Finder 保持一致

Zipic Filesize

有用户反馈:“Zipic 显示的文件大小和 Finder 里看到的不一样,是不是压缩有问题?”

这让我很困惑,文件大小就是用 FileManager 获取的 fileSize 属性,怎么会不对?仔细对比后发现差异确实存在,比如一个文件 Finder 显示 1.26 MB,Zipic 显示 1.2 MB。虽然差别不大,但对于一个“精确压缩”的工具来说,这种不一致会让用户产生疑虑。

问题出在“文件大小”这个概念本身。文件系统中大小有两种:

  • 逻辑大小(Logical Size):文件实际包含的数据量
  • 物理大小(Physical Size):文件在磁盘上占用的空间

Finder 默认显示的是逻辑大小,而且使用的是 1000 进制(1 KB = 1000 bytes),而不是程序员习惯的 1024 进制。要获取与 Finder 一致的文件大小,需要用 URLresourceValues 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 提升一个台阶的最简单可行的方式。

Zipic Filesize
  • 亲密性(Proximity):相关的元素放在一起。Zipic 的压缩列表里,每个文件项的缩略图、文件名、大小、压缩率紧挨着,形成一个视觉单元;而不同文件之间有明显的间距。用户扫一眼就能分清哪些信息属于同一张图片。设置面板也是,按功能分组——输出设置放一块,质量设置放一块,不会把“输出路径”和“压缩质量”混在一起。
Design Alignment
  • 对齐(Alignment):所有元素都要有视觉上的连接。Zipic 的侧边栏、列表视图、详情面板,相同元素内所有文字都有对齐。看起来是小事,但如果对齐乱了,整个界面会显得“业余”。SwiftUI 的 alignment 参数用得很多,.leading.trailing.center 要根据内容类型选择。
Design Repetition
  • 重复(Repetition):统一的视觉元素贯穿始终。Zipic 里的圆角半径是统一的,所有可点击的文字都用同一种颜色。图标风格也统一——要么都用 SF Symbols,要么都用自定义图标,不混搭。这种一致性让用户觉得“这是一个完整的产品”,而不是“拼凑出来的”。
Design Contrast
  • 对比(Contrast):重要的东西要突出。压缩率是用户最关心的数据,所以用蓝色渐变标签显示;文件名次要,用常规字体;原始大小更次要,用浅灰色。主次分明,用户不用思考就知道该点哪个。

这四个原则听起来简单,但真正用好需要不断调整。我的做法是:写完代码先跑起来看效果,觉得哪里“不对劲”就用这四个原则去检查。是不是相关元素没有靠近?是不是对齐出了问题?是不是重复的元素不够统一?是不是该突出的没突出?

没有设计稿的好处是迭代快——改几行代码就能看到效果,不用先改设计稿再改代码。当然这种方式有个前提:脑子里要有清晰的设计原则,不然很容易改着改着就乱了。

核心功能的技术实现

接下来分享几个核心功能的实现细节,这些都是在 macOS 平台上比较有意思的技术点。

文件夹监控自动压缩

Zipic Directory Monitor

文件夹监控是 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 原生能力

Zipic PDF Filter Compression

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 Image Thumbnail

Zipic 的列表视图需要展示大量图片缩略图。最初的实现很朴素:用 AsyncImage 直接加载原图,让系统自动缩放显示。小图没问题,但遇到大图就明显卡顿,内存也跟着飙升。

第一次优化:预生成缩略图缓存

思路很直接——既然每次都要缩放,不如预先生成好缩略图存到磁盘,下次直接加载小图。早期实现用的是 NSImagelockFocus/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.jpgb/photo.jpg 的缩略图分别存到 thumbnails/a/photo.pngthumbnails/b/photo.png

这个优化的核心原则是:对于只需展示缩略图的场景,永远不要完整解码原图。ImageIO 的下采样是 Apple 官方推荐的最佳实践。

设备指纹稳定性优化

Zipic Fingerprint Issue

一度收到用户反馈“没有换电脑却需要重新激活”——排查后发现旧版指纹算法包含了会变化的主机名,网络环境变化或系统更新都可能让 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()
}

踩坑经验

  • 静默迁移策略:验证失败时自动回退用旧指纹再试,若旧指纹有效则后台注销旧设备并以新指纹重新激活,全流程用户无感知
  • 网络错误容错:仅在明确的业务错误码下才注销授权,对超时、网络抖动等异常只记录日志与重试,不改变本地授权状态

批量压缩并发优化

Zipic Accelerate Compression

当用户一次性拖入几百张图片时,如何高效处理是个技术挑战。最初的串行方案——一张一张压缩——显然太慢了,但简单地开满并发也会带来问题:系统卡顿、内存飙升、任务调度混乱。

最终方案是 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 扩展

Zipic URL Scheme Extention

工具类产品要真正融入用户的工作流,光靠自己的界面是不够的。用户可能在用 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 平台上创造属于自己的独立产品。

我们江湖再见!