我是如何将 MagicToy(SwiftData) 的剪切板性能提升了将近100倍


MagicToy 的剪切板功能支持复制文字、带有格式的代码或表格、网页、图片等。

当用户复制了大量的图片,或者图片数量虽然不多,但是单个图片的尺寸非常大,会造成剪切板非常卡顿。

MagicToy 致力于为用户提供好用的各种小工具,而一个卡顿的剪切板和“好用”离了十万八千里。

趁着为 MagicToy 添加直接粘贴到其它程序功能的机会,我准备着手修复这个问题。经过非常小的修改,MagicToy 的剪切板加载10张5K图片的性能提升了接近100倍(从1300ms变成了从15ms),想知道我是怎么做的吗?请继续往下看。

直接粘贴到其它程序

为了更清楚地展示我是如何修复这个问题,最好向各位说明一下 MagicToy 的剪切板的界面、数据结构和业务逻辑。

MagicToy 的剪切板是什么样子

MagicToy 希望为用户提供一个简单,但是安全的剪切板。这就是它的样子:

如果你不喜欢它默认的样子,你可以在十余种精美的主题中选择你喜欢的一个。你甚至可以自定义它的各个部分的颜色、字体、大小、边框等等。

但是最重要的,还是它是一款安全的剪切板。通过设置剪切板项目的最长保留时间,它将帮助您减少敏感信息的暴露时间,从而保护您的密码、账户、钱包秘钥或者助记词等等。

此外,它还支持预览代码、文字、图片、备注、搜索、收藏。

而今天我们要说的,就是这个支持显示图片以及预览图片的功能。

数据结构

为了更有效率的介绍本文的主题,我将省去一些细节,只保留足以说明问题的代码。

@Model
public class ClipboardItem: Identifiable, Equatable, ObservableObject {
    public var uid = UUID()
    @Attribute(.externalStorage)
    private var data: [Data?]
}

如上所示,MagicToy 使用 SwiftData 存储剪切板数据。SwiftData简化了用户使用sqlite的步骤,比CoreData更简单(实际上我学习Swift及SwiftUI只用了半天的时间,我曾经尝试过CoreData但根本没有配置成功)。通过.externalStorage,我将剪切板的数据存放到了表外空间。

然后,使用一个List来查询和展示这些剪切板项目:

struct ClipboardView: View {
    @Environment(.modelContext) private var modelContext
    @State var hoveringItem: ClipboardItem?

    @Query
    private var items: [ClipboardItem]
    var body: some View {
      List($items) { item in
         ...
      }
    }
}

打开Xcode工程sql debug

为了能够看到获取数据的时间,可以在Xcode的如下位置打开sql debug:

MagicToy存在的问题

通过观察 Xcode 的控制台输出的 sql 日志,我发现了两个问题:

  1. 当鼠标悬浮到剪切板列表的时候,发生了sql查询
  2. 当查询尺寸巨大的图片的时候,单条sql的执行时间非常慢,可能要100ms左右

所以我需要做的,就是

  1. 减少没有必要的查询,比如鼠标悬浮到列表上,这种场景根本无需查询。我需要搞明白为什么会发生重新渲染
  2. 压缩图片,减少加载剪切板项目时,从数据库读取数据的时间

修复

减少无必要的查询

这是最简单的一步。

当鼠标悬浮到剪切板项目时,整个ClipboardView发生了重新渲染,而罪魁祸首就是@State修饰的hoveringItem。

通过将悬浮的逻辑从List,转移到List中的每一个条目,该问题可以很简单的解决掉。

优化数据结构,并利用缩略图减少数据加载时间

只有用户希望粘贴的时候,我们才需要提供给用户原图。除此以外的场景,如剪切板条目以及预览场景,使用多个大小不一的缩略图即可。

当程序启动,剪切板加载数据的时候,只需要加载缩略图,当用户粘贴的时候,再读取原图,可以大大减少数据加载的时间。

基于此思路,我需要实现两个功能:

  1. 生成缩略图
  2. 分离缩略图和原图的数据到不同的表

生成缩略图

这部分没有太多需要说的,我可以分享我使用的压缩函数:

extension NSImage {
    func resize(_ to: CGSize, isPixels: Bool = false) -> NSImage {

        var toSize = to
        let screenScale: CGFloat = NSScreen.main?.backingScaleFactor ?? 1.0

        if isPixels {

            toSize.width = to.width / screenScale
            toSize.height = to.height / screenScale
        }

        let toRect = NSRect(x: 0, y: 0, width: toSize.width, height: toSize.height)
        let fromRect =  NSRect(x: 0, y: 0, width: size.width, height: size.height)

        let newImage = NSImage(size: toRect.size)
        newImage.lockFocus()
        draw(in: toRect, from: fromRect, operation: NSCompositingOperation.copy, fraction: 1.0)
        newImage.unlockFocus()

        return newImage
    }
}

分表

新的表结构长这样:

@Model
public class ClipboardItemV2: Identifiable, Equatable, ObservableObject {
    public var uid = UUID()
    @Attribute(.externalStorage)
    private var thumbnail: [Data?]
}

@Model
public class ClipboardRawData: Identifiable, Equatable, ObservableObject {
    public var uid = UUID()
    @Attribute(.externalStorage)
    private var data: [Data?]
}

并且在需要使用这些数据的地方,修改为使用缩略图,而只有在粘贴的时候,才去加载未压缩的原始数据。

没错,SwiftData 为你做好了这些按需加载的操作,你只需要想清楚你要什么即可。

通过这样的改动,MagicToy的剪切板即使没有在内存中做任何缓存,速度也提升了将近100倍。

以前:

CoreData: annotation: total fetch execution time: 0.0750s for 4 rows.

现在:

CoreData: annotation: total fetch execution time: 0.0008s for 4 rows.

750➗8 = 93.75! 我的天,提升了100倍!

想获取 MagicToy 吗?你可以点击下方的图标,免费试用它。

MagicToy on AppStore

发表评论