掌握 TipKit:进阶

2023-11-01 17:16:41 浏览数 (1)

在上文中,我们介绍了 TipKit 的基础用法。在本文中,我们将讨论一些与 TipKit 相关的进阶问题,例如如何完全自定义 Tip 视图(不使用 TipView 和 popoverTip)、如何在 UIKit 中使用 TipKit,以及 TipKit 如何在不同的应用程序之间共享数据。最后,我们将尝试解答一些与 TipKit 相关的疑惑。

欢迎访问 fatbobman.substack.com[4] 订阅 Fatbobman's Swift Weekly 的中英文电子邮件版本。

透过现象看本质

TipKit 框架极大地简化了在应用程序中添加提示的难度。通过使用像TipViewpopoverTip这样现成的提示视图,开发者可以专注于提示的内容,而不必过多关心视觉效果的实现。

然而,这些预制的提示视图仅仅是 TipKit 提供的辅助工具。TipKit 的真正精髓在于它采用了“契约式设计”的理念。换句话说,TipKit 允许你用代码的形式定义提示的内容和显示规则,而不需要考虑具体的实现。这些规则和内容构成了你和 TipKit 之间的一个契约。TipKit 会根据这个契约动态决定是否需要显示提示,开发者只需关注状态或事件的变化。

所以,TipKit 的精髓不在于外在的视觉效果,而在于内在的逻辑表达。它帮助开发者以声明的方式描述提示生成的规则,而提示的具体实现完全可以自定义。我们可以把 TipKit 想象成一个判断提示显示需求的规则引擎,至于如何可视化这些规则,则取决于开发者自己。

如何观察 Tip 的状态

既然我们将 TipKit 视作一个判断提示显示需求的规则引擎,那么 TipKit 是否为开发者提供了观察某个 Tip 状态的 API 呢?答案是肯定的。

TipKit 为 Tip 的实例提供了两个方法:statusUpdatesshouldDisplayUpdates,它们分别返回了两个 AsyncStream,用于提供该 Tip 类型的状态变化和显示与否的信息。

statusUpdates 会返回 Tip 的三种状态:pending(不符合显示条件)、available(符合显示条件)、invalidated(失效及失效原因)。

shouldDisplayUpdates 则简化了上述内容,仅通过 truefalse 来表示是否可以显示 Tip 视图。

其中,pending 对应 falseavailable 对应 true。当一个 Tip 被设置为失效后,TipKit 在发送最后一个状态变化信息(invalidated)后,将不再观察该 Tip 的参数和事件的变化,停止继续提供状态信息。

让我们使用以下代码来演示观察某个 Tip 状态的过程:

代码语言:javascript复制
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 失效。

https://cdn.fatbobman.com/tipkit-status-stream-demo_2023-10-18_15.57.18.2023-10-18 15_59_07.gif

也许你已经注意到了,在当前的视图中我们没有添加 TipViewpopoverTip,这完全验证了上文中提到的“规则引擎”概念。是否展示 Tip 视图完全取决于开发者。

TipKit 为 Tip 还提供了两个属性,statusshouldDisplay ,考虑到 Tip 的状态会经常变化,而这两个属性并不具备良好的观察方式,因此不建议完全依赖这两个属性来判断当前 Tip 的状态。

根据状态展示自定义 Tip 视图

一旦开发者掌握了观察 Tip 状态的方式,就可以轻松在应用中根据状态展示任何形式和样式的提示视图。

代码语言:javascript复制
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
                }
            }
        }
    }
}

https://cdn.fatbobman.com/tipkit-show-tip-by-status-demo_2023-10-18_16.55.40.2023-10-18 16_56_22.gif

在 UIKit 和 AppKit 中使用 TipKit

由于 UIKit 和 AppKit 并非响应式的框架,即使使用 TipKit 提供的预制 Tip 视图(TipUIView[5]TipUIPopoverViewController[6]TipUICollectionViewCell[7]TipNSView[8]TipNSPopover[9]),开发者也需要显式地跟踪 Tip 的状态,然后根据状态显示 Tip 视图。

以下代码摘自苹果的官方文档:

代码语言:javascript复制
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 协议中,titlemessageimage 等属性类型均为 SwiftUI 特有的类型,因此如果想在 UIKit 或 AppKit 中实现完全自定义视图,最好在声明 Tip 类型时为其添加其他的附加信息,以方便使用。

关于 TipKit 的几个疑问

TipKit 通过代码的形式让开发者定义 Tip 的内容、显示规则以及影响规则的参数和事件。那么 TipKit 是如何理解用户定义的“Tip”呢?是将一个符合 Tip 协议的类型视为一个 Tip,还是将一个用该类型创建的实例视为一个 Tip?

从接触 TipKit 开始,一直有几个疑问困扰着我:

  • 在一个应用中,是否可以在多个视图中使用同一个 Tip 类型?
  • 同一个 Tip 类型的不同实例是否可以返回不一样的属性值(比如 titlerules)?
  • 在不同的应用之间( AppGroup ),是否可以使用同一个 Tip 定义?Tip 的状态是否可以同步?
  • 怎样才算是同一个 Tip 的定义?是指完全相同的代码吗?
  • TipKit 会持久化哪些 Tip 状态?共享 Tip 间状态同步的机制是什么?
  • @Parameter 是否有类型限制?

对于上述疑问,无论是在 TipKit 的文档还是 WWDC 有关 TipKit 的 Session[10] 中,都没有给出清晰的解释。幸好,TipKit 采用了我们熟悉的数据持久化机制,我们可以从中找到我们想要的答案。

在进一步寻找答案之前,我们首先需要了解以下几点:

  • Tip 中的参数(Parameter)和事件(Event)是以静态属性的形式声明的。
  • 对参数的修改以及对事件的触发和查询无需通过实例。
  • TipView 和 popoverTip 需要使用 Tip 实例作为参数。
  • 观察 Tip 的状态需要通过实例。

从 TipKit 的持久化数据中找寻答案

考虑到 TipKit 需要保存的数据量和数据类型的多样性,UserDefaults 显然不是一个好的选择。最终,我们在应用的 Application Support 目录中找到了 TipKit 的持久化数据(在未指定目录和设置 AppGroupIdentifier 的情况下)。TipKit 将数据保存在名为 .tipkit 的目录中的 tips-store.db 文件里。

打开数据库文件后,我们就能看到熟悉的 Core Data 数据格式的身影。

请阅读 Core Data 是如何在 SQLite 中保存数据的[11] 一文,了解 Core Data 的持久化数据格式。

https://cdn.fatbobman.com/image-20231019105146293.png

TipKit 一共创建了 5 个实体( Entity ),分别是:CoreTipRecord、CoreParameterRecord、CoreEventRecord、CoreDonationRecord 和 CoreRuleRecord。了解了这五个实体的构成,对解答上面的疑问很有帮助。

CoreTipRecord

保存与 Tip 相关的信息,包括显示日期、次数、Option 设定等。大致的定义如下:

代码语言:javascript复制
class CoreTipRecord {
    // Tip Type Name , for example : MyTip
    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>
    var events: Set<CoreEventRecord>
}

为了方便阅读,我们将不再使用 NSManagedObject 的方式来进行类型的定义。

其中 id 为 Tip 的类型名称,也就是说,下面的代码对应的 CoreTipRecord 的 id 值为 MyTip

代码语言:javascript复制
struct MyTip: Tip {}

无论创建多少个 MyTip 实例,它们都对应着同一个 CoreTipRecord 记录。而且,对于通过 App Group 进行 TipKit 数据共享的应用来说,只要是类型名称为 MyTip,它们都对应着同一个 CoreTipRecord 数据。

tipInfo 中保存了与该提示相关的其他一些信息,例如:

  • 显示记录:所有的显示日期,无论在哪个应用( App Group )中对该 Tip 进行显示
  • 已显示次数
  • 最大显示次数设定( Option )
  • 是否忽略显示频次策略( Option )

只要显示 Tip,显示日期都将被记录。同样,最大显示次数设定适用于 App Group 中的所有成员,并且显示状态在不同成员之间共享。

由于 Tip 的 Option 也被进行了持久化,因此应在不同的应用中(App Group)采用相同的 Option 设置。

实践发现,如果在不同的应用中采用了不同的 Option 设置,后启动的会覆盖之前的设置,不推荐这种做法。

CoreParameterRecord

CoreParameterRecord 大致的定义如下:

代码语言:javascript复制
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 )信息。

代码语言:javascript复制
struct MyTip: Tip {
  @Parameter
  static var show:Bool = false
}

上面的代码中,对应 CoreParameterRecord 的数据为:

  • id:Bool.MyTip show ,属性类型 Tip 类型名称 属性名称
  • valueType:字符串 Bool
  • valueData: Bool.false 的 Encode 数据

从中我们可以看出,TipKit 并没有对 @Parameter 所能支持的数据类型做出太多的限制,类型只需符合 Encodable 协议即可。

代码语言:javascript复制
struct MyData: Codable {
  var id: String
  var count: Int
}

struct MyTip: Tip {
  @Parameter
  static var data: MyData = MyData(id:"1", count: 1)
}

很遗憾,受限于当前 Predicate 的问题,我们还无法使用以下规则(该规则将在运行时会导致应用崩溃):

代码语言:javascript复制
var rules: [Rule] {
    #Rule(Self.$data){
        $0.count > 3
    }
}

CoreEventRecord

下面是 CoreEventRecord 的大致定义,它用于记录与 Event 定义相关的信息。

代码语言:javascript复制
class CoreEventRecord {
    // event property name
    var id: String
    // No data recorded yet
    var eventInfo: [String: Any]

    var donations: Set<CoreDonationRecord>
    var rules: Set<CoreRuleRecord>

    var tip: CoreTipRecord?
}

在多个应用(AppGroup)甚至多个设备上(iCloud 同步)触发同一个事件时,所有的触发数据都是共享的。

代码语言:javascript复制
static let didTriggerControlEvent = Event(id: "didTriggerControlEvent")

CoreEventRecord 中 iddidTriggerControlEvent

CoreDonationRecord

CoreDonationRecord 的定义如下:

代码语言:javascript复制
class CoreDonationRecord {
    var date: Date
    var donationInfo: DonationInfo?

    var event: CoreEventRecord
}

用来记录 Donation 的日期,每次触发都会记录一条数据。

代码语言:javascript复制
MyTip.didTriggerControlEvent.sendDonation()

https://cdn.fatbobman.com/image-20231019184513075.png

由于 TipKit 尚未公开 DonationInfo,所以我们无法在触发事件时附带自定义的信息。如果未来开放了自定义 EventInfo 的能力,就可以创建更加灵活的规则。

CoreRuleRecord

CoreRuleRecord 的定义如下,用于记录 Tip 的 Rule 设定:

代码语言:javascript复制
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 的自定义版本的字符串表述。

代码语言:javascript复制
var rules: [Rule] {
    #Rule(Self.didTriggerControlEvent){
        $0.donations.count > 3
    }
}

id 为:

代码语言:javascript复制
MyTip.event.didTriggerControlEvent.count(donationsCount) > Optional(3)

每个 Rule 保存为一条 CoreRuleRecord 记录。在验证时,它们之间是 AND 的关系。

代码语言:javascript复制
    var rules: [Rule] {
        #Rule(Self.didTriggerControlEvent){
            $0.donations.count > 3
        }
        #Rule(Self.$show){
            $0
        }
    }

https://cdn.fatbobman.com/image-20231019193739995.png

释疑

通过对 TipKit 持久化数据的分析并结合我做的另外一些测试,我们基本上可以得出以下结论:

  • TipKit 的数据是通过 Core Data 来管理并保存的
  • 因为 Core Data 的关系,TipKit 的数据可以在不同的应用(AppGroup)或不同的设备间(iCloud)进行共享和同步
  • TipKit 根据 Tip 的类型名称来标识 Tip。在不同的应用(AppGroup)之间,相同的 Tip 类型名称将使用同一个 Tip 数据源。
  • 在不同的应用(AppGroup)之间,对于同一个 Tip 类型,如果不复用代码,所有的持久化属性(包括静态属性)应该保持一致,包括:Parameter、Event、rules、options。
  • 与外观有关的属性可以在创建实例时根据需要进行修改和调整,比如:标题(title)、消息(message)、图片(image)、操作(action)。
  • 同一个 Tip 的失效状态、显示状态、点击次数、允许的最大展示量等都是共享的。
  • 同一个 Tip 的事件触发数据也是共享的。

无论是在 WWDC 的演讲中,还是在 tipInfo 的信息中,都表明 TipKit 支持通过 iCloud 进行同步。然而,我尚未找到正确的开启方式。如果有人成功实现了,请告诉我一下。

最后

在本文中,我们从“规则引擎”的角度对 TipKit 进行了分析。尽管分析显示开发团队预留了一些升级空间,但 TipKit 的设计主旨是为了方便在应用中展示 Tip 信息,因此在数据筛选效率和规则制定灵活性方面,并没有过度增加不必要的能力。即便如此,TipKit 还是为我们提供了一个实现可共享数据的微型“规则引擎”的良好范例。

参考资料

[1]

掌握 TipKit:基础: https://www.fatbobman.com/posts/mastering-TipKit-basic/

[2]

www.fatbobman.com: https://www.fatbobman.com

[3]

Discord 频道: https://discord.gg/ApqXmy5pQJ

[4]

fatbobman.substack.com: https://fatbobman.substack.com

[5]

TipUIView: https://developer.apple.com/documentation/tipkit/tipuiview

[6]

TipUIPopoverViewController: https://developer.apple.com/documentation/tipkit/tipuipopoverviewcontroller

[7]

TipUICollectionViewCell: https://developer.apple.com/documentation/tipkit/tipuicollectionviewcell

[8]

TipNSView: https://developer.apple.com/documentation/tipkit/tipnsview

[9]

TipNSPopover: https://developer.apple.com/documentation/tipkit/tipnspopover

[10]

Session: https://developer.apple.com/videos/play/wwdc2023/10229/

[11]

Core Data 是如何在 SQLite 中保存数据的: https://www.fatbobman.com/posts/tables_and_fields_of_CoreData/

0 人点赞