解决 SwiftUI 痛点与性能瓶颈:Zipic 开发技术复盘

这是 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) 修饰符:

Swift
WindowGroup {
    ContentView()
}
.windowStyle(.hiddenTitleBar)
.containerBackground(.clear, for: .window)

这个修饰符告诉系统窗口的容器背景应该是透明的,配合 .hiddenTitleBar 终于实现了完全无标题栏的效果。

具体适配细节可以参考:macOS 开发 - macOS 26 中隐藏主窗口标题栏

踩坑经验:如果应用使用了 NavigationSplitView,还需要额外处理侧边栏的标题。默认情况下侧边栏会显示一个 inline 样式的导航标题,也需要隐藏:

Swift
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:

Swift
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey])
let fileSize = resourceValues.fileSize ?? 0

格式化显示时,要用 ByteCountFormatter 并指定正确的选项:

Swift
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 占用极低。

Swift
// 使用 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 里,于是又触发了监控事件……如此往复,无限循环。

为此我设计了预测性忽略机制。当检测到输入文件时,系统会预测输出文件的路径,提前加入忽略列表:

Swift
let predictor = FileTransformPredictor.imageCompression(suffix: "_compressed")
let predictedOutputs = predictor.predictOutputFiles(for: inputURL)
ignoreList.addPredictiveIgnore(predictedOutputs)

过滤系统也很重要。用户可能只想压缩特定类型的图片,或者只处理大于某个尺寸的文件。我采用了谓词模式来实现灵活的条件组合:

Swift
// 只监控图片文件,大于 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 文件生成):

Swift
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 压缩核心逻辑):

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

关键代码(页面渲染实现):

Swift
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 方法:

Swift
// 早期版本(已废弃)
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 手动创建位图上下文:

Swift
// 改进版本(仍有问题)
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,可以直接从图片文件生成指定尺寸的缩略图,内部采用渐进式下采样,根本不需要完整解码原图。这才是正解。

Swift
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 地址参与计算:

Swift
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 负载均衡分配):

Swift
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]
    }
}

关键代码(批量压缩流程):

Swift
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,支持传入多个文件路径和压缩参数。

Swift
// 注册 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 秒。

TypeScript
// 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 平台上创造属于自己的独立产品。

我们江湖再见!

About Author

十里ZipicOrchardHiPixelTimeGo Clock 等应用的作者,喜欢做「小而美」的产品——功能不贪多,但每个细节都想抠到位。 如果你也在折腾独立产品,欢迎来聊!

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅