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 日志,我发现了两个问题:
- 当鼠标悬浮到剪切板列表的时候,发生了sql查询
- 当查询尺寸巨大的图片的时候,单条sql的执行时间非常慢,可能要100ms左右
所以我需要做的,就是
- 减少没有必要的查询,比如鼠标悬浮到列表上,这种场景根本无需查询。我需要搞明白为什么会发生重新渲染
- 压缩图片,减少加载剪切板项目时,从数据库读取数据的时间
修复
减少无必要的查询
这是最简单的一步。
当鼠标悬浮到剪切板项目时,整个ClipboardView发生了重新渲染,而罪魁祸首就是@State修饰的hoveringItem。
通过将悬浮的逻辑从List,转移到List中的每一个条目,该问题可以很简单的解决掉。
优化数据结构,并利用缩略图减少数据加载时间
只有用户希望粘贴的时候,我们才需要提供给用户原图。除此以外的场景,如剪切板条目以及预览场景,使用多个大小不一的缩略图即可。
当程序启动,剪切板加载数据的时候,只需要加载缩略图,当用户粘贴的时候,再读取原图,可以大大减少数据加载的时间。
基于此思路,我需要实现两个功能:
- 生成缩略图
- 分离缩略图和原图的数据到不同的表
生成缩略图
这部分没有太多需要说的,我可以分享我使用的压缩函数:
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 吗?你可以点击下方的图标,免费试用它。
