本文首发《老司机周报》@小专栏

快速备忘录是什么?

快速备忘录本质是备忘录应用的一个扩展,它的目标是方便用户在已支持快速备忘录功能的 App 里(如 Safari)快速记录内容,目前支持内容的格式包括,文字、图片、地图、链接(以卡片的形式)。并且在再次回到上次发生过记录行为的地方会有高亮提示(称之为快速备忘录建议),方便回顾和修改整理。再搭配这次 iOS 15/ macOS Monterey 升级的备忘新功能——支持的 @能力、# tag 、搜索、分享能力,让 Apple 设备上的资料整理变的前所未有的高效。

快速备忘录如何使用

我们以在 macOS Monterey 上使用为例(本 session 里是使用支持 Apple Pencil 的 iPad 演示,原理一样)。任一设备上创建的 Notes 都是跨平台共享的,在 iPad 上创建的 Notes 可以在 macOS 、iPhone 上看到。如何使用?

  • 桌面唤醒 按住 Command 键,将鼠标置于整个屏幕右下角(双屏的用户主屏幕的右下角),此时会展示小卡片,

桌面唤醒

这个行为是可以配置的,路径是 Preferences -> Mission Control而不是 Preferences -> Screen SaverScreen Hot Corners. 需要同时按住 Command 键

  • 在 Safari 里唤醒(目前 beta 版本只有 Safari 支持) 也有两种方式,一种是选中文字(注意不能选中编辑状态的文字),点击右键菜单, 选中 New Quick Note, Add To Quick Note,此时会唤起右下角悬浮卡片(此时不能打开 Notes App,否则只会直接打开 Notes App,而不是悬浮卡片)。 选择新建 A 可以看到对双屏用户,悬浮菜单的位置不对,我在上面的屏幕操作,悬浮卡片在下面屏幕。点击悬浮卡片,展开悬浮的备忘编辑器(这时候悬浮编辑器又是在上面的屏幕,位置是对的)。

这里的Add To Quick Note 行为不符合常理,按照我的理解:应该是添加到这个页面已有的备忘才对;而事实的行为是它会添加的你上一个备忘里,没有区分是否是相同 URL 的页面里选中的文字(猜测和 Safari 15 对 Quick Notes 的适配逻辑有关系)

第二种添加方式,在新版本浏览器的地址栏,点击更多按钮,选中Create Quick Note,

很遗憾,点击后在我的电脑上没有什么反应,什么都没有发生,期待下次 beta 能够 work。

快速备忘录和备忘录的关系

简单是说 快速备忘录是备忘录的一个快捷入口,被保存的快速备忘录最后会变成备忘录的一个新分组Quick Notes,展示在所有分组最上面;所有 Quick Notes 和其他备忘录里的 Notes 没有区别(除了在 Notes App 里不能被锁定外)。另外本次 macOS Menterey 还可以通过 iPhone 在备忘里增加手写体,保存为图片和 Apple Pencil 的输出一样。

快速备忘录和备忘录 App 是独立的窗体,如果你已经打开了 A,想回到备忘录 App,iPad 上可以点击右上角工具栏里的 Notes 图标,但是在 macOS 上我没找到任何途径,除非手动在菜单里选择 Window -> Notes

快速备忘录支持的格式包括文字、图片、地图、链接(以卡片的形式),而备忘录 App 支持的格式远不止如此,Objective-c 源码、mp4 源文件等大部分支持 URI 访问的资源都可以支持,他们会被当作附件的方式保存在备忘的文件系统里。

更牛的是这些视频和源码都会被自动同步到云端(多端冲突处理逻辑待查),用户完全不用关心被保存在哪儿,所以可以放心的把它当作富邮件客户端使用了。

快速备忘录的工作原理

快速备忘录技术上沿用里 NSUserActivity 的接口,而 NSUserActivity 之前被广泛使用在其他保持 App 的运行态的功能里,如 Handoff \ Universal Link \ Widget 打开 Host App 流程等。源 App 将现在正在发生的事件,用 NSUserActivity 的类型封装这些状态,生成实例,注册到 User activity system系统。而 User activity system系统管理着这些 NSUserActivity 实例,传递给目标 App,目标 App 接收到这些 NSUserActivity 实例后作出相应的逻辑处理——相信上面的这些步骤你已经很熟悉了。 Handoff 之前到流程 当快速备忘录加入到 User activity system系统后,系统还会把这些 NSUserActivity 实例同时发送给快速备忘录,这时候会触发你的 App 的 Add Link 菜单和快速备忘录建议。 加入快速备忘录后到流程

支持快速备忘录的关键字段

快速备忘录复用了 NSUserActivity 对象里业已存在的 3 个字段来支持功能实现;

  • targetContentIdentifier
  • persistentIdentifier
  • webpageURL

不同的功能使用不同的字段;

  1. targetContentIdentifier也用于 App 的状态恢复,服务于多窗口、多任务时的内容接力;
  2. persistentIdentifier也用于 Spotlight 里用来标识应用的内容,正如其名称所指,它服务于被持久化的内容,系统还提供了根据此字段删除 NSUserActivity 实例的接口;
  3. webpageURL表明源 App 活动内容是个网页,用于 Handoff Universal Link等场景;

为了能够支持快速备忘录能够实现跨平台的体验,以上字段的值需要满足一定的要求;

  1. 唯一性,在多次内容的收集中需要有不同的 id;需要注意不要和其他 App 的值冲突;
  2. 全局性,除了考虑当前设备外,还需要考虑多个设备上相同内容里生成的值的唯一性;
  3. 稳定性,今天在本 App 里生成的内容,在半年之后,重新打开相同界面能够使用相同的标识来保存快速备忘录。这也是快速备忘录建议能够出现的前提条件。如果你用过类似 Safari 插件 Liner或者 LightNote for macOS, 你在某个网页之前收集的文字,下次打开后会高亮显示,容许你再次编辑修改。

如何配置 App 支持 快速备忘录

下面我新建 QuickNote 的 iOS 应用来演示如何配置。

1. 声明支持 ActivityType 类型

info.plist 文件里声明 ActivityType,以让User activity system系统过滤掉本 App 不关心的 NSUserActivity 的实例,也防止信息泄漏。通常这些可以使用 bundleId 作为前缀作为唯一性保证。 声明支持的活动类型

2. 注册本 App 的活动状态

在合适的时机,创建描述当前活动状态的 NSUserActivity 对象实例,注册到User activity system系统。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        self.view.backgroundColor = .red
      
        // 创建 activity
        let activity = NSUserActivity (activityType: "me.hite.app.QuickNote.type1")
        activity.title = "I am in ViewController and background color is red."
        // persistentIdentifier 也可以,这样 Spotlight 也可以搜到
        activity.persistentIdentifier = "me.hite.app.QuickNote.type1_\(ViewController.self)"
        activity.userInfo = ["timestamp": "\(Date.now)", "text": "我是支持 Spotlight 的#吃饭#下雨"]
        // 注册为最新的 activity。
        self.userActivity = activity
    }
}

当前活动的管理可以让 UIKit 来帮我们在合适的时机注册。上面例子则表示是 ViewController 活动时激活、销毁后去掉激活还可以手动管理。在特殊的逻辑里,也可以自己手动管理。如 Session 里举的例子,图片浏览器使用过程中,当前查看的图片即为最新活动内容。

1
2
3
// 手动管理
activity.becomeCurrent();
activity.resignCurrent();

3. 接收第三方或者其他设备传入的 NSUserActivity 对象

具体做法是实现和 userActivity 相关的 UIWindowSceneDelegate 的几个方法;

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    func scene(_ scene: UIScene, continue userActivity: NSUserActivity ) {
        print("continue userActivity = \(userActivity)")
    }
    
    func scene(_ scene: UIScene, willContinueUserActivityWithType userActivityType: String) {
        print("willContinueUserActivityWithType = \(userActivityType)")
    }
    
    func scene(_ scene: UIScene, didFailToContinueUserActivityWithType userActivityType: String, error: Error) {
        print("didFailToContinueUserActivityWithType = \(error)")
    }

我按照 session 的步骤完成了 demo,一个 iOS 应用,一个 macOS 应用。百般调试没发现如何在 App 里激活/ 唤起 快速备忘录的悬浮窗里的 Add Link;经过网络搜索发现也有很多人有相同的疑问,个别能成功的也是在类似演示中的 Apple Pencil + iPad 场景;详细见论坛讨论,如果你已经实现了麻烦也留言给我;如果我找到了途径或者 macOS beta 更新后能够实现,我会第一时间更新在 demo 工程里。

适配快速备忘录的最佳实践

NSUserActivity 是 App 活动转发的网关,它也是其他一些特性的基础。为了支持快速备忘录而实现的上述接口,还能得到其他一些特性加持——默认 handoff 上启用的,其它特性也可按需求加入(比如 Siri 建议、Siri 提醒等),比如你的 App 支持使用 App 内的文件生成提醒;或者让你 App 内发表的 blog 出现在 Spotlight 的搜索结果里。

而支持 NSUserActivity 有一些最佳实践想和大家分享,共包含 4 块内容;

1. 标题

标题是供人阅读的,需要对备忘的内容更强概括和描述,这些标题会在 Add Link 菜单里展示,通常而言我们使用文章或者网页的标题就可以了。

2. 标识

targetContentIdentifier,persistentIdentifier。如何取一个唯一、全局、长期稳定有效的标识?比分说避免使用和设备相关的数据;不要使用转瞬即逝的状态信息,如 session ID、某个特定视图的属性;图片自身的名字也不是个长期稳定的标识,因为可能会被修改,这样做无法保证返回到上个界面时,这些名称还依然存在。如果说图片,可以考虑使用 App 保存的 UUID 作为图片的标识,即使内容被移动到别的地方依然能够重新定位到。

通常,URL 是不错的唯一性标识,但是它有时候会代表一些临时存在的信息,不符合上述里描述的稳定性原则。如果 webpageURL 符合标识的要求,你也可以放心使用。但快速备忘录里优先选择用 targetContentIdentifier,persistentIdentifier两个字段,尤其是如果 NSUserActivity 被用于状态恢复和 Spotlight 时,建议这两个字段都设置上,设置为相同的值。如果正好这个场景有对应的页面程序(如电商 App 里的详情页面),那么把 webpageURL 作为兜底设置,以便当 App 没有安装时,可以打开对应的网页。

3. 持续更新活动的状态

另一个重要的实践是确保应用的当前 NSUserActivity 实例是最新的。这意味着要跟上正在发生的事情。最佳实践是在检测到任何活动更改时,也要随之更新标题和标识符,保持属性的准确性,比如选择查看不同的照片。不建议复用 NSUserActivity 实例。当有新内容时,比如新照片,创建一个新 NSUserActivity 。 为了持续更新活动的状态,我们最容易想到是每次状态修改时,修改 NSUserActivity 对象,如在地图应用中,用户的缩放比例和查看的位置被保持在 NSUserActivity 对象的 userInfo 对象里。但是随着每次用户操作都去更新这两个值,是个不小的开销,这时候,我们可以使用 needsSave字段。这样当User activity system系统需要传递或者持久化 NSUserActivity 对象时,会回调一个接口来询问最新状态,这是去更新缩放比例和位置的最佳时机。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
activity.needsSave = true



func userActivityWillSave(_ userActivity: NSUserActivity ) {
    userActivity.userInfo = [
        "center" : visibleFrame.middle
        "zoomScale" : scrollView.zoomScale
    ]
}

iOS 里类似性能优化的设计如, - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath API_AVAILABLE(ios(7.0))@property (nonatomic) CGFloat estimatedRowHeight API_AVAILABLE(ios(7.0))

4. 版本兼容性问题

随着 App 的更新,需要考虑以下两种情况;

  • 新版本支持旧版本的链接
  • 旧版本需要不认识的链接,丢弃而不是触发崩溃; 当遇到 link 丢失或者找不到时,需要显示合适的错误信息,以及默认的逻辑行为,提供用户体验。

适配快速备忘录让你的 App 内容融入到系统里的备忘工作流中,紧密的串联起人、内容和你的 App。确保你已经适配了 NSUserActivity,现在正是重新检查已有代码,提升他们到新高度的好机会。设置唯一、全局、稳定的标识,把 NSUserActivity 交给合适的响应者,让系统管理当前的 NSUserActivity ,处理其他所有的工作。

如何看待快速备忘录的推出

任何一个平台在迭代的时候都会考虑两个来源的声音:1. 团队内部的路线图。2. 社区的呼声。而快速备忘录正是苹果社区考察后选择里 Apple 生态圈里有广泛用户使用需求,但是限于第三方服务,没有到达苹果标准的资料收集类 App 作为标的。实现网页收集资料的需求,Chrome、Firefox 上有大量的插件,我使用过很多,大部分还属于实验室作品,体验极差。而 Safari 上 Liner,是最靠近我需要的插件,但是网页访问速度慢、还要收费;所以我去年在 WWDC 后用它推荐的 JS SDK 参考 Liner 做了一个 Safari 插件 LightNote,利用苹果的 iCloud Drive 存储,在本地开服务器实现了个简化版。

而今年的快速备忘录推出,完全宣判了 LightNote 类 App 的死刑。快速备忘录的服务可不仅仅是网页,还支持把 App 内的内容也纳入到资料池里,尤其是它拥有系统级的快捷入口。而留给 Liner 和 LightNote 的出路,可能就是内容聚合和多浏览器、多平台支持这些特性了。 其实那些做 OCR、翻译、天气的 App 在 iOS 15 里遭遇了相同的命运,这也是一个生态发展的无奈、必经之路。

目前的 BUG

  • Safari 里重新打开任意页面都会出现左下角悬浮窗,应该是只有我生成过快速备忘录的页面出现才对
  • 在 iPad 上,添加到 快速备忘录也是失效的,点了没有反应
  • 在非 iPad Pro (和 Apple Pencil)之外,不知道怎么触发 Add Link 能力。iPhone 里也支持 快速备忘录?怎么使用,难以想象,希望官方能够给个例子。

附录