本文中咱们将讨论在 SwiftUI 视图中批量获取 Core Data 数据的办法,并测验创立一个可以运用 mock 数据的 FetchRequest。由于本文会触及很多 前文 中介绍的技巧和办法,因而最好一起阅览。
- SwiftUI 与 Core Data —— 问题
- SwiftUI 与 Core Data —— 数据界说
原文发表在我的博客wwww.fatbobman.com
欢迎订阅我的公共号:【肘子的Swift记事本】
创立可运用 Mock 数据的 FetchRequest
FetchRequest 单向数据流的破坏者?
对于每一个在 SwiftUI 中运用 Core Data 的开发者来说,@FetchRequest 都是绕不开的话题。FetchRequest 极大地简化了在视图中获取 Core Data 数据的难度,配合 @ObservedObject( 保管方针契合 ObservableObject 协议 ),仅需几行代码,开发者便可以让视图完成对数据改动的实时呼应。
但对于选用单向数据流办法的开发者,@FetchRequest 就像悬挂在头顶的达摩克利斯之剑,一直让其介意。类 Redux 结构一般都主张开发者将整个 app 的状况合成到一个单一的结构实例中( State ,契合 Equatable 协议 ),视图经过调查状况的改动( 有些结构支持切片式的调查以改进功能 )而做出呼应。而 @FetchRequest 将 app 中状况构成中的很大一部分从独立的结构实例平分拆出来,散落在多个视图之中。这几年不少开发者也测验找寻更加契合 Redux 精力的替换方案,但作用都不了解。
我也做了不少的测验,但终究发现好像 FetchRequest 仍是当时 SwiftUI 中的最优解。简略介绍一下我探索进程( 以 TCA 结构进行举例 ):
-
在 Reducer 中获取并办理值数据
在 task( 或 onAppear )中经过发送 Action 启动一个长期的 Effect ,创立一个 NSFetchedResultsController 从 Core Data 中获取指定谓词的数据集。在 NSFetchedResultsControllerDelegate 完成中将保管方针转换成对应的值类型,并传递给 Reducer 。
在 State 中运用 IdentifiedArray 类型保存数据集,以便经过 .forEach 对 Reducer 进行拆分。
上述做法确实是彻底契合 Redux 精力的一种办法,但由于在将保管方针转换到值类型这一进程中咱们抛弃了 Core Data 的懒加载这一特性,因而一旦数据量较多将导致严峻的功能和内存占用问题。因而,只适合数据集较小的运用场景。
-
在 Reducer 中获取并办理 AnyConvertibleValueObservableObject
相似上面的办法,但省略了转换成值类型的进程,将保管方针包装成 AnyConvertibleValueObservableObject ,直接在 State 中保存引证类型。不过考虑到 TCA 在之后会将 Reducer 移出主线程,从线程安全的视点动身,终究抛弃了该方案。
由于终究咱们需求在视图中运用 AnyConvertibleValueObservableObject( 保管方针 ),因而数据的获取进程有必要是在主线程上下文中进行的( 数据绑定的上下文是 ViewContext ),Reducer 一旦从主线程中移出的话,意味着 AnyConvertibleValueObservableObject 会被保存在非线程的 State 实例中。虽然在实践中,假如能在确保不拜访保管方针的非线程安全特色的前提下,在非创立保管方针的线程中持有保管方针并不会呈现溃散的状况,但出于谨慎的考虑,我终究仍是抛弃了这种办法。
-
在 Reducer 中获取并办理 WrappedID
相似上面的办法,仅在 State 中保存线程安全的 WrappedID( 包装后的 NSManagedObjectID )。在视图中经过 WrappedID 获取到对应的 AnyConvertibleValueObservableObject 或值类型。虽然会增加一点视图的代码量,但这种办法无论从数据流的处理仍是线程安全的视点来说几乎都是完美的。
不过,终究让我抛弃上面一切测验的原因仍是由于功能问题。
- 任何 Core Data 数据的改动都将导致 app 的单一 State 产生改动,虽然 TCA 有切分机制,但随着运用复杂程度和数据量的增加,因对 State 进行比对而产生的功能问题将越发严峻
- 创立 NSFetchedResultsController 并获取第一批数据的操作是从 onAppear 中建议的,由于 TCA 的 Action 处理机制,数据的初次显现有可感知的推迟( 作用远不如在视图中经过 FetchRequest 获取 )
- 由于 TCA 的 Reducer 无法与视图的存续期主动绑定,上面的可感知推迟在每次触发 onAppear 时都将呈现
终究,我决议放下心结,依然选用在视图中运用相似 @FetchRequest 的办法来获取数据。经过新创立一个可以运用 Mock 数据的 FetchRequest ,完成了 SwiftUI 与 Core Data —— 问题 一文中提出的可测验、可预览、可模块化的方针。
NSFetchedResultsController
NSFetchedResultsController 经过 NSFetchRequest 从 Core Data 中获取特定的数据集,并将数据集发送至契合 NSFetchedResultsControllerDelegate 协议实例中完成办法,以完成在屏幕上显现数据的目的。
简略地来说,NSFetchedResultsController 便是在初次获取数据集( performFetch )后,对 NSManagedObjectContextObjectsDidChange 以及 NSManagedObjectContextDidMergeChangesObjectIDs 告诉进行呼应,并根据告诉内容( insert、delete、update 等 )主动更新内存中数据集。为了进步 UITableView( UICollectionView )的更新效率,NSFetchedResultsController 会将数据的改动分解成特定的动作( NSFetchRequestResultType )以便利开发者快速调整 UITableView 的显现内容( 无需改写全部的数据 )。
惋惜的时,NSFetchedResultsController 为 UITableView 准备的基于 NSFetchRequestResultType 优化操作在 SwiftUI 中并不起作用。在 SwiftUI 中,ForEach 会根据数据标识( Identifier )主动处理视图的添加、删除等操作,因而,当在 SwiftUI 中运用 NSFetchedResultsController 时,只需求完成 NSFetchedResultsControllerDelegate 中的 controllerDidChangeContent(_ controller: )
办法即可。
自界说契合 DynamicProperty 协议的类型
在 SwiftUI 中,常见的可以作为 Source of truth 的类型均契合 DynamicProperty 协议。DynamicProperty 协议为数据供给了拜访 SwiftUI 保管数据池的才能。经过未公开的 _makeProperty
办法,数据可以在 SwiftUI 数据池中请求空间进行保存并读取。这将有两个作用:
- 数据改动后将引发与其绑定的视图进行更新
- 由于底层数据并不保存在视图中,因而在视图存续期中 SwiftUI 可以随时创立新的视图描述实例而无需忧虑数据丢失
虽然苹果没有公开 _makeProperty
办法的具体细节,开发者无法自行向 SwiftUI 请求数据保存地址,但可以经过在自界说的类型中( 契合 DynamicProperty 协议 )运用系统供给的契合 DynamicProperty 协议的类型( 如 State )完成相似的作用。
在创立自界说 DynamicProperty 类型时,需求注意以下几点:
-
可以在自界说类型中运用环境值或环境方针
在视图被加载后,视图中一切契合 DynamicProperty 协议的类型也将一起具有拜访环境数据的才能。但假如在视图没有加载或没有供给环境值( 例如忘掉注入环境方针,没有供给正确的视图上下文 )的状况下拜访环境数据,将引发运用溃散。
-
当 SwiftUI 在视图存续期中重新创立视图描述实例时,自界说类型也将一起重新创立
在视图存续期中,假如 SwiftUI 立异创立了视图描述实例,那么无论视图描述( 契合 View 协议的 Struct )中的特色是否契合 DynamicProperty ,都将被重建。这意味着,有必要将需求耐久化的数据( 与视图存续期共同 )保存在系统供给的 DynamicProperty 类型中。
-
视图被 SwiftUI 加载后才会调用 update 办法
DynamicProperty 协议仅有公开的办法是 update ,SwiftUI 将在视图初次被加载以及契合 DynamicProperty 类型中的可引发视图更新的数据产生改动后调用该办法。由于类型的实例在视图存续期中可能会反复地被创立,因而对数据的准备( 例如初次获取 NSFetchedResultsController 数据、创立订阅联系 )以及更新作业都应在该办法中进行。
-
不可在 update 办法中同步地改动引发视图更新的数据
与 SwiftUI 在视图中更新 Source of truth 的逻辑共同,在一个视图更新周期中,不能对 Source of truth 再度更新。这意味着,虽然咱们只能在 update 办法中更改数据,但有必要要想办法错开该更新周期。
MockableFetchRequest 的运用办法
MockableFetchRequest 供给与 FetchRequest 相似的动态获取数据的才能,但它有如下的特色:
-
MockableFetchRequest 回来 AnyConvertibleValueObservableObject 类型的数据
MockableFetchRequest 中的 NSFetchedResultsController 会将数据直接转换为 AnyConvertibleValueObservableObject 类型,一方面可以在视图中直接享用前文中介绍的各种优点,另一方面也可以防止在视图中声明 MockableFetchRequest 时,运用具体的保管方针类型,有利于模块化开发。
@MockableFetchRequest(\ObjectsDataSource.groups) var groups // 代码不会被具体的保管方针类型所污染
-
经过环境值切换数据源
在前文中,咱们经过创立契合 TestableConvertibleValueObservableObject 协议的数据为一个包括单个 AnyConvertibleValueObservableObject 方针的视图供给了无需保管环境的预览才能。MockableFetchRequest 则为一个获取数据集的视图供给了无需保管环境预览一组数据的才能。
首要,咱们需求创立一个契合 ObjectsDataSourceProtocol 协议的类型, 经过让特色为 FetchDataSource 类型来指定数据源。
// MockableFetchRequest 代码中已包括
public enum FetchDataSource<Value>: Equatable where Value: BaseValueProtocol {
case fetchRequest // 经过 MockableFetchRequest 中的 NSFetchedResultsController 获取数据
case mockObjects(EquatableObjects<Value>) // 运用供给的 Mock 数据
}
public extension EnvironmentValues {
var dataSource: any ObjectsDataSourceProtocol {
get { self[ObjectsDataSourceKey.self] }
set { self[ObjectsDataSourceKey.self] = newValue }
}
}
// 开发者需求自界说的代码
public struct ObjectsDataSource: ObjectsDataSourceProtocol {
public var groups: FetchDataSource<TodoGroup>
}
public struct ObjectsDataSourceKey: EnvironmentKey {
public static var defaultValue: any ObjectsDataSourceProtocol = ObjectsDataSource(groups: .mockObjects(.init([MockGroup(.sample1).eraseToAny()]))) // 设置默许的数据源来自 mock 数据
}
可以在预览的时分对数据进行实时修正( 详情请参阅 Todo 中的 GroupListContainer 代码 )。
当运用运行于保管环境时,仅需供给正确的视图上下文,并将 dataSource 中的特色值修正成 fetchRequest 即可。
-
答应在构造办法中不供给 NSFetchRequest
当在视图中运用 @FetchRequest 时,咱们有必要在声明 FetchRequest 变量时设置 NSFetchRequest( 或者 NSPredicate )。如此一来,在将视图提取到一个单独的 Package 时,仍需导入包括具体 Core Data 保管方针界说的库,无法做到彻底的解耦。在 MockableFetchRequest 中,无需在声明时供给 NSFetchRequest,可以在视图加载时,动态地为 MockableFetchRequest 供给所需的 NSFetchRequest( 具体演示代码 )。
public struct GroupListView: View {
@MockableFetchRequest(\ObjectsDataSource.groups) var groups
@Environment(\.getTodoGroupRequest) var getTodoGroupRequest
public var body: some View {
List {
ForEach(groups) { group in
GroupCell(
groupObject: group,
deletedGroupButtonTapped: deletedGroupButtonTapped,
updateGroupButtonTapped: updateGroupButtonTapped,
groupCellTapped: groupCellTapped
)
}
}
.task {
guard let request = await getTodoGroupRequest() else { return } // 在视图加载时经过环境办法获取所需的 Request
$groups = request // 动态对 MockableFetchRequest 设置
}
.navigationTitle("Todo Groups")
}
}
- 防止对不引发 ID 改动的操作更新数据集
当数据集的 ID 次序或数量没有产生改动时,即使数据的特色值产生改动,MockableFetchRequest 也不会更新数据集。由于 AnyConvertibleValueObservableObject 本身契合 ObservableObject 协议,因而虽然 MockableFetchRequest 没有更新数据集,但视图仍会对 AnyConvertibleValueObservableObject 中的特色改动进行呼应。这样可以减少 ForEach 数据集的改动频次,改进 SwiftUI 的视图效率。
- 供给了一个更加轻盈的 Publisher 以监控数据改动
原版的 FetchRequest 供给了一个 Publisher( 经过投影值 ),会对每次的数据集改动做出呼应。不过该 Publisher 的呼应过于频频,即使数据会集仅有一个数据的特色产生改动,也会下发数据会集的一切数据。MockableFetchRequest 对此进行了简化,仅会在数据集产生改动时,下发一个空的告诉( AnyPublisher<Void, Never>
)。
public struct GroupListView: View {
@MockableFetchRequest(\ObjectsDataSource.groups) var groups
public var body: some View {
List {
...
}
.onReceive(_groups.publisher){ _ in
print("data changed")
}
}
}
假如需求完成与 @FetchRequest 一样的作用,仅需进步 sender 特色的权限即可
下图为彻底依赖 mock 数据创立的预览演示:
MockableFetchRequest 代码阐明
本节仅对部分代码进行阐明,完整代码请于此处查看。
-
怎么防止更新数据与 update 周期重合
在 MockableFetchRequest 中,咱们经过一个类型为
PassthroughSubject<[AnyConvertibleValueObservableObject<Value>], Never>
的 Publisher,统一办理两个不同的数据源。经过运用 delay 操作符,便可以完成对数据的错峰更新。 如有需求,也可以经过创立 Task 完成对数据的异步更新。
cancellable.value = sender
.delay(for: .nanoseconds(1), scheduler: RunLoop.main) // 推迟 1 纳秒即可
.removeDuplicates {
EquatableObjects($0) == EquatableObjects($1)
}
.receive(on: DispatchQueue.main)
.sink {
updateWrappedValue.value($0)
}
-
用引证类型包装需求修正的数据,防止引发视图的不必要的更新
经过创立一个具有包装用处的引证类型来持有需求修正的数据( 在 @State 中持有引证 ),便可以达到如下目的:1、让数据的生命周期与视图生存期共同;2、数据可更改;3、更改数据不会引发视图更新。
extension MockableFetchRequest {
// 包装类型
final class MutableHolder<T> {
var value: T
@inlinable
init(_ value: T) {
self.value = value
}
}
}
public struct MockableFetchRequest<Root, Value>: DynamicProperty where Value: BaseValueProtocol, Root: ObjectsDataSourceProtocol {
@State var fetcher = MutableHolder<ConvertibleValueObservableObjectFetcher<Value>?>(nil)
func update() {
...
// fetcher 是可耐久的,修正 fetcher.value 不会引发视图更新
if let dataSource = dataSource as? Root, case .fetchRequest = dataSource[keyPath: objectKeyPath], fetcher.value == nil {
fetcher.value = .init(sender: sender)
if let fetchRequest {
updateFetchRequest(fetchRequest)
}
}
...
}
}
-
怎么比较两个
[AnyConvertibleValueObservableObject<Value>]
是否相同由于 Swift 无法直接对包括相关类型的数据进行持平比较,因而创立了一个中间类型 EquatableObjects ,并让其契合 Equatable 协议以便利对两个
[AnyConvertibleValueObservableObject<Value>]
数据进行比较,防止不必要的视图改写。
public struct EquatableObjects<Value>: Equatable where Value: BaseValueProtocol {
public var values: [AnyConvertibleValueObservableObject<Value>]
public static func== (lhs: Self, rhs: Self) -> Bool {
guard lhs.values.count == rhs.values.count else { return false }
for index in lhs.values.indices {
if !lhs.values[index]._object.isEquatable(other: rhs.values[index]._object) { return false }
}
return true
}
public init(_ values: [AnyConvertibleValueObservableObject<Value>]) {
self.values = values
}
}
// in MockableFetchRequest
if let dataSource = dataSource as? Root, case .mockObjects(let objects) = dataSource[keyPath: objectKeyPath],
objects != EquatableObjects(_values.wrappedValue) // 去重
{
sender.send(objects.values)
}
...
cancellable.value = sender
.delay(for: .nanoseconds(1), scheduler: RunLoop.main)
.removeDuplicates {
EquatableObjects($0) == EquatableObjects($1) // 去重
}
.receive(on: DispatchQueue.main)
.sink {
updateWrappedValue.value($0)
}
-
经过操作底层数据处理无法在闭包中引进 self 的问题
在订阅闭包中运用底层数据,如此就可以绕过无法在结构体中引进 self 的问题。
let values = _values // 对应的类型是 State ,也便是 MockableFetchRequest 的 values 特色的底层数据
let firstUpdate = firstUpdate
let animation = animation
updateWrappedValue.value = { data in
var animation = animation
if firstUpdate.value {
animation = nil
firstUpdate.value = false
}
withAnimation(animation) {
values.wrappedValue = data // 对底层数据进行操作
}
}
SectionedFetchRequest
我暂时没有对另一个获取数据的办法 SectionedFetchRequest 进行改动。首要的原因是没有想好要怎么地安排回来数据。
当时,SectionedFetchRequest 在数据量较大时会有较严峻的功能问题。这是由于一旦 SwiftUI 的慵懒容器中呈现了多个 ForEach ,慵懒容器将丧失对子视图的优化才能。任何数据的变动,慵懒容器都将对一切的子视图进行更新而不是仅更新可见部分的子视图。
SectionedFetchRequest 回来的数据类型为 SectionedFetchResults ,可以将其视为一个以 SectionIdentifier 为键的有序字典。读取其数据必定会在慵懒容器中运用多个 ForEach ,然后引发功能问题。
@SectionedFetchRequest<String, Quake>(
sectionIdentifier: \.day,
sortDescriptors: [SortDescriptor(\.time, order: .reverse)]
)
private var quakes: SectionedFetchResults<String, Quake>
List {
ForEach(quakes) { section in
Section(header: Text(section.id)) {
ForEach(section) { quake in
QuakeRow(quake: quake)
}
}
}
}
我目前有两种构想:
- 将一切的数据以一个数组进行回来( sectionIdentifier 为首要排序条件 ),并一起供给每个 Section 在回来数组中对应的起始 offset( 或对应的 ID )以及该 Section 中的数据量。
- 将一切的数据以一个数组进行回来( sectionIdentifier 为首要排序条件 ),在每个 Section 头尾刺进特定的 AnyConvertibleValueObservableObject 数据( 由于 WrappedID 的存在,咱们可以很简单创立 mock 数据 )
无论上述哪种办法,开发者都需抛弃运用 SwiftUI 原生的 Section 功能,在慵懒容器中,根据供给的附加数据自行对数据做分段显现处理。
Core Data 本身并不具有直接从 SQLite 中获取分组记录的才能,目前的完成办法是以 sectionIdentifier 为首要排序条件获取一切的数据。然后经过 propertyToGroupBy 对 sectionIdentifier 进行分组,获取每组的数据量( count )。经过回来的统计信息,核算每个 Section 的偏移量。
本文总结及下文介绍
本文中咱们创立了可以支持 mock 数据的 FetchRequest ,并简略介绍了在自界说契合 DynamicProperty 协议的类型时需求注意的事项。
鄙人一篇文章中,咱们将讨论怎么在 SwiftUI 中安全地呼应数据,怎么防止由于数据意外丢失而导致的行为反常以及运用溃散。
希望本文可以对你有所协助。一起也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行交流。
订阅下方的 邮件列表,可以及时取得每周的 Tips 汇总。
原文发表在我的博客wwww.fatbobman.com
欢迎订阅我的公共号:【肘子的Swift记事本】