在上文中,我们介绍了 TipKit 的基础用法。在本文中,我们将讨论一些与 TipKit 相关的进阶问题,例如如何完全自定义 Tip 视图(不使用 TipView 和 popoverTip)、如何在 UIKit 中使用 TipKit,以及 TipKit 如何在不同的应用程序之间共享数据、如何复用 Tip 声明代码。并且,我们将尝试解答一些与 TipKit 相关的疑惑。
如果你对 TipKit 框架还不太了解,请先阅读 掌握 TipKit:基础 这篇文章。
2024 年 9 月更新:在 WWDC 2024 上,TipKit 框架的功能得到了显著扩展。本系列文章已针对最新更新进行了修订。
透过现象看本质
TipKit 框架极大地简化了在应用程序中添加提示的难度。通过使用像TipView
和popoverTip
这样现成的提示视图,开发者可以专注于提示的内容,而不必过多关心视觉效果的实现。
然而,这些预制的提示视图仅仅是 TipKit 提供的辅助工具。TipKit 的真正精髓在于它采用了“契约式设计”的理念。换句话说,TipKit 允许你用代码的形式定义提示的内容和显示规则,而不需要考虑具体的实现。这些规则和内容构成了你和 TipKit 之间的一个契约。TipKit 会根据这个契约动态决定是否需要显示提示,开发者只需关注状态或事件的变化。
所以,TipKit 的精髓不在于外在的视觉效果,而在于内在的逻辑表达。它帮助开发者以声明的方式描述提示生成的规则,而提示的具体实现完全可以自定义。我们可以把 TipKit 想象成一个判断提示显示需求的规则引擎,至于如何可视化这些规则,则取决于开发者自己。
如何观察 Tip 的状态
既然我们将 TipKit 视作一个判断提示显示需求的规则引擎,那么 TipKit 是否为开发者提供了观察某个 Tip 状态的 API 呢?答案是肯定的。
TipKit 为 Tip 的实例提供了两个方法:statusUpdates
和shouldDisplayUpdates
,它们分别返回了两个 AsyncStream,用于提供该 Tip 类型的状态变化和显示与否的信息。
statusUpdates
会返回 Tip 的三种状态:pending
(不符合显示条件)、available
(符合显示条件)、invalidated
(失效及失效原因)。
而 shouldDisplayUpdates
则简化了上述内容,仅通过 true
和 false
来表示是否可以显示 Tip 视图。
其中,pending
对应 false
,available
对应 true
。当一个 Tip 被设置为失效后,TipKit 在发送最后一个状态变化信息(invalidated
)后,将不再观察该 Tip 的参数和事件的变化,停止继续提供状态信息。
让我们使用以下代码来演示观察某个 Tip 状态的过程:
struct DemoTip: Tip {
var title: Text = .init("Hello World")
@Parameter
static var show: Bool = false
var rules: [Rule] {
#Rule(Self.$show) {
$0
}
}
}
struct TipStatusView: View {
let tip = DemoTip()
var body: some View {
List {
Button("Show Toggle") {
DemoTip.show.toggle()
}
Button("Invalidate") {
tip.invalidate(reason: .actionPerformed)
}
}
.task {
for await status in tip.statusUpdates {
print("Status:", status)
}
}
.task {
for await shouldDisplay in tip.shouldDisplayUpdates {
print("Display:", shouldDisplay)
}
}
}
}
点击 “Show Toggle” 按钮,将改变 DemoTip.show
的值,从而影响 TipKit 根据 rules 进行判断的结果。点击 “Invalidate” 将使该 Tip 失效。
也许你已经注意到了,在当前的视图中我们没有添加 TipView
或 popoverTip
,这完全验证了上文中提到的“规则引擎”概念。是否展示 Tip 视图完全取决于开发者。
TipKit 为 Tip 还提供了两个属性,
status
和shouldDisplay
,考虑到 Tip 的状态会经常变化,而这两个属性并不具备良好的观察方式,因此不建议完全依赖这两个属性来判断当前 Tip 的状态。
根据状态展示自定义 Tip 视图
一旦开发者掌握了观察 Tip 状态的方式,就可以轻松在应用中根据状态展示任何形式和样式的提示视图。
struct TipStatusView: View {
let tip = DemoTip()
@State var shouldDisplay = DemoTip.show
var body: some View {
List {
if shouldDisplay {
tip.title
}
Button("Show Toggle") {
DemoTip.show.toggle()
}
Button("Invalidate") {
tip.invalidate(reason: .actionPerformed)
}
}
.task {
for await shouldDisplay in tip.shouldDisplayUpdates {
withAnimation(.smooth) {
self.shouldDisplay = shouldDisplay
}
}
}
}
}
在 UIKit 和 AppKit 中使用 TipKit
由于 UIKit 和 AppKit 并非响应式的框架,即使使用 TipKit 提供的预制 Tip 视图(TipUIView、TipUIPopoverViewController、TipUICollectionViewCell、TipNSView、TipNSPopover),开发者也需要显式地跟踪 Tip 的状态,然后根据状态显示 Tip 视图。
以下代码摘自苹果的官方文档:
import TipKit
import UIKit
struct CatTracksFeatureTip: Tip {
var title: Text { Text("Sample tip title")}
var message: Text? { Text("Sample tip message")}
var image: Image? { Image(systemName: "globe")}
}
class CatTracksViewController: UIViewController {
private var catTracksFeatureTip = CatTracksFeatureTip()
private var tipObservationTask: Task<Void, Never>?
private weak var tipView: TipUIView?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
tipObservationTask = tipObservationTask ?? Task { @MainActor in
for await shouldDisplay in catTracksFeatureTip.shouldDisplayUpdates {
if shouldDisplay {
let tipHostingView = TipUIView(catTracksFeatureTip)
tipHostingView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tipHostingView)
view.addConstraints([
tipHostingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
tipHostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20.0),
tipHostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20.0)
])
tipView = tipHostingView
}
else {
tipView?.removeFromSuperview()
tipView = nil
}
}
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
tipObservationTask?.cancel()
tipObservationTask = nil
}
需要提醒一下,因为在 Tip 协议中,title
、message
、image
等属性类型均为 SwiftUI 特有的类型,因此如果想在 UIKit 或 AppKit 中实现完全自定义视图,最好在声明 Tip 类型时为其添加其他的附加信息,以方便使用。
关于 TipKit 的几个疑问
TipKit 通过代码的形式让开发者定义 Tip 的内容、显示规则以及影响规则的参数和事件。那么 TipKit 是如何理解用户定义的“Tip”呢?是将一个符合 Tip 协议的类型视为一个 Tip,还是将一个用该类型创建的实例视为一个 Tip?
从接触 TipKit 开始,一直有几个疑问困扰着我:
- 在一个应用中,是否可以在多个视图中使用同一个 Tip 类型?
- 同一个 Tip 类型的不同实例是否可以返回不一样的属性值(比如
title
、rules
)? - 在不同的应用之间( AppGroup ),是否可以使用同一个 Tip 定义?Tip 的状态是否可以同步?
- 怎样才算是同一个 Tip 的定义?是指完全相同的代码吗?
- TipKit 会持久化哪些 Tip 状态?共享 Tip 间状态同步的机制是什么?
@Parameter
是否有类型限制?
对于上述疑问,无论是在 TipKit 的文档还是 WWDC 有关 TipKit 的 Session 中,都没有给出清晰的解释。幸好,TipKit 采用了我们熟悉的数据持久化机制,我们可以从中找到我们想要的答案。
在进一步寻找答案之前,我们首先需要了解以下几点:
- Tip 中的参数(Parameter)和事件(Event)是以静态属性的形式声明的。
- 对参数的修改以及对事件的触发和查询无需通过实例。
- TipView 和 popoverTip 需要使用 Tip 实例作为参数。
- 观察 Tip 的状态需要通过实例。
从 TipKit 的持久化数据中找寻答案
考虑到 TipKit 需要保存的数据量和数据类型的多样性,UserDefaults 显然不是一个好的选择。最终,我们在应用的 Application Support
目录中找到了 TipKit 的持久化数据(在未指定目录和设置 AppGroupIdentifier 的情况下)。TipKit 将数据保存在名为 .tipkit
的目录中的 tips-store.db
文件里。
打开数据库文件后,我们就能看到熟悉的 Core Data 数据格式的身影( 在 WWDC 2024 中,苹果表示 TipKit 使用了 SwiftData 来实现持久化 )。
请阅读 Core Data 是如何在 SQLite 中保存数据的 一文,了解 Core Data 的持久化数据格式。
TipKit 一共创建了 5 个实体( Entity ),分别是:CoreTipRecord、CoreParameterRecord、CoreEventRecord、CoreDonationRecord 和 CoreRuleRecord。了解了这五个实体的构成,对解答上面的疑问很有帮助。
CoreTipRecord
保存与 Tip 相关的信息,包括显示日期、次数、Option 设定等。大致的定义如下:
class CoreTipRecord {
// Tip 的标识,如果未自定义 `id`,则 `id` 默认为 Tip 的类型名称
var id: String
// pending , available , invalidated
var statusValue: Int
// Reason of invalidated
var invalidationReasonValue: Int
// The lost display date
var lastDisplayed: Date?
// Unclear
var content: ConstellationContent?
// Some info of Tip , including : display count, options setting, etc
var tipInfo: [String: Any]
var rules: Set<CoreRuleRecord>
}
为了方便阅读,我们将不再使用 NSManagedObject 的方式来进行类型的定义。
id
是 Tip 的唯一标识符。例如,下面的代码中 MyTip
的 id
值为 my id 1
:
struct MyTip: Tip {
var id: String {
"my id 1"
}
}
如果未自定义 id
,则 id
默认为 Tip 的类型名称。例如,下面的代码在持久化后对应的 id
为 MyTip
:
struct MyTip: Tip {}
id
属性作为 Tip 的标识非常重要,它意味着同一 Tip 声明可以通过调整其 id
值来创建不同的存储记录,这也是实现 Tip 声明复用的基础。
tipInfo
中保存了与该提示相关的其他一些信息,例如:
- 显示记录:所有的显示日期,无论在哪个应用( App Group )中对该 Tip 进行显示
- 已显示次数
- 最大显示次数设定( Option )
- 是否忽略显示频次策略( Option )
只要显示 Tip,显示日期都将被记录。同样,最大显示次数设定适用于 App Group 中的所有成员,并且显示状态在不同成员之间共享。
由于 Tip 的 Option 也被进行了持久化,因此应在不同的应用中(App Group)采用相同的 Option 设置。
实践发现,如果在不同的应用中采用了不同的 Option 设置,后启动的会覆盖之前的设置,不推荐这种做法。
CoreParameterRecord
CoreParameterRecord 大致的定义如下:
class CoreParameterRecord {
// Composite name of a paramter property
var id: String
// The name of the paramter property type
var valueType: String
// Encoded default value
var valueData: Data?
var rules: Set<CoreRuleRecord>
}
从 CoreParameterRecord 的命名上很容易看出,这个对象用于保存 Tip 中的参数( Parameter )信息。
struct MyTip: Tip {
@Parameter
static var show:Bool = false
}
上面的代码中,对应 CoreParameterRecord 的数据为:
- id:
Bool.MyTip+show
,属性类型 + Tip 类型名称 + 属性名称 - valueType:字符串
Bool
- valueData: Bool.false 的 Encode 数据
从中我们可以看出,TipKit 并没有对 @Parameter
所能支持的数据类型做出太多的限制,类型只需符合 Encodable 协议即可。
struct MyData: Codable {
var id: String
var count: Int
}
struct MyTip: Tip {
@Parameter
static var data: MyData = MyData(id:"1", count: 1)
}
很遗憾,受限于当前 Predicate 的问题,我们还无法使用以下规则(该规则将在运行时会导致应用崩溃):
var rules: [Rule] {
#Rule(Self.$data){
$0.count > 3
}
}
CoreEventRecord
下面是 CoreEventRecord 的大致定义,它用于记录与 Event 定义相关的信息。
class CoreEventRecord {
// event property name
var id: String
// No data recorded yet
var eventInfo: [String: Any]
var donations: Set<CoreDonationRecord>
var rules: Set<CoreRuleRecord>
}
在多个应用(AppGroup)甚至多个设备上(iCloud 同步)触发同一个事件时,所有的触发数据都是共享的。
static let didTriggerControlEvent = Event(id: "didTriggerControlEvent")
CoreEventRecord 中 id
为 didTriggerControlEvent
。
在事件的持久化数据中,并未创建与 Tip 直接的关联。这意味着,TipKit 依赖事件的 id
值来区分不同的事件。 为了避免事件名称重复(尤其是在 Tip 声明复用的情况下),建议将相关事件定义在一起:
enum MyTipEvents {
static let didTriggerControlEvent = Tips.Event(id: "didTriggerControlEvent")
static let didVisitCount = Tips.Event(id: "didVisitCount")
}
在 Tip 规则中,应使用统一的事件声明:
struct Tip1: Tip {
...
var rules: [Rule] {
#Rule(MyTipEvents.didTriggerControlEvent){
$0.donations.count > 3
}
}
}
struct Tip2: Tip {
...
var rules: [Rule] {
#Rule(MyTipEvents.didVisitCount){
$0.donations.count > 5
}
}
}
CoreDonationRecord
CoreDonationRecord 的定义如下:
class CoreDonationRecord {
var date: Date
var donationInfo: DonationInfo?
var event: CoreEventRecord
}
用来记录 Donation 的日期,每次触发都会记录一条数据。
MyTip.didTriggerControlEvent.sendDonation()
由于 TipKit 尚未公开 DonationInfo,所以我们无法在触发事件时附带自定义的信息。如果未来开放了自定义 EventInfo 的能力,就可以创建更加灵活的规则。
CoreRuleRecord
CoreRuleRecord 的定义如下,用于记录 Tip 的 Rule 设定:
class CoreRuleRecord {
var id: String
var categoryValue: Int
var statusValue: Int
var predicate: Predicate
var ruleInfo: [String: Any]
var event: CoreEventRecord?
var parameter: CoreParameterRecord?
var parent: CoreRuleRecord?
var subrules: Set<CoreRuleRecord>
var tip: CoreTipRecord?
}
其中,id 是最有意思的属性,它 Rule 中 Predicate 的自定义版本的字符串表述。
var rules: [Rule] {
#Rule(Self.didTriggerControlEvent){
$0.donations.count > 3
}
}
id
为:
MyTip.event.didTriggerControlEvent.count(donationsCount) > Optional(3)
每个 Rule 保存为一条 CoreRuleRecord 记录。在验证时,它们之间是 AND
的关系。
var rules: [Rule] {
#Rule(Self.didTriggerControlEvent){
$0.donations.count > 3
}
#Rule(Self.$show){
$0
}
}
释疑
通过对 TipKit 持久化数据的分析及一系列测试,我们得出以下关键结论:
- TipKit 通过 SwiftData 管理和保存数据。
- 借助 SwiftData,TipKit 数据可以在不同应用(AppGroup)或跨设备(通过 iCloud)共享和同步。
- TipKit 通过 Tip 的
id
来标识不同的 Tip。即便使用相同的 Tip 声明,只要id
不同,就会被视为不同的 Tip 实例。 - Tip 的外观相关属性,如标题(title)、消息(message)、图片(image)和操作(action),都可以在创建实例时根据需要进行修改和调整。
- 同一 Tip 的失效状态、显示状态、点击次数及允许的最大展示量等信息是共享的。
- 事件与 Tip 之间没有直接关联,事件依赖于
id
进行识别,同一id
的事件触发数据是共享的。
Tip 的复用机制
在上文中,我们讨论了 TipKit 如何依赖 Tip 的 id
来识别不同的 Tip 实例。通过覆盖 Tip 的默认标识符,你可以基于其内容重复使用相同的提示结构体,从而提高代码的复用性和效率。
在以下示例中,我们定义了一个 ItemTip
,但通过在构建 Tip 实例时赋予不同的 id
,实现了使用同一段代码生成多个具体的 Tip 实例。
struct Item: Identifiable {
let title: String
var id: String {
title
}
}
struct ItemTip: Tip {
let item: Item
var title: Text {
Text(item.title)
}
var id: String {
item.id
}
}
let items: [Item] = [
.init(title: "hello"),
.init(title: "fatbobman"),
.init(title: "world"),
]
struct ReuseDemo: View {
@State var tips = TipGroup(.ordered) {
ItemTip(item: items[0])
ItemTip(item: items[1])
ItemTip(item: items[2])
}
var body: some View {
List {
TipView(tips.currentTip)
ForEach(items) { item in
Text(item.title)
}
}
}
}
当我们需要为动态添加的数据提供提示时,可以根据新数据构建不同的 id
。这种 Tip 的复用机制极大地优化了代码的组织和维护,减少了重复声明的需要。
最后
在本文中,我们从“规则引擎”的角度对 TipKit 进行了分析。尽管分析显示开发团队预留了一些升级空间,但 TipKit 的设计主旨是为了方便在应用中展示 Tip 信息,因此在数据筛选效率和规则制定灵活性方面,并没有过度增加不必要的能力。即便如此,TipKit 还是为我们提供了一个实现可共享数据的微型“规则引擎”的良好范例。