Skip to content

Commit

Permalink
Adds the ability to integrate with collection/table view prefetch
Browse files Browse the repository at this point in the history
Prefetch operations are requested from CellActions and executed in a private OperationQueue. The actual fetching/cancelling is delayed by a short period of time because iOS may trigger rapid fetch/cancel operations and this is an attempt to reduce the amount of fetch/cancel/fetch of the same IndexPath
  • Loading branch information
g-Off committed Jan 17, 2020
1 parent 5e88bc6 commit 911e56a
Show file tree
Hide file tree
Showing 8 changed files with 367 additions and 9 deletions.
8 changes: 8 additions & 0 deletions FunctionalTableData.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
6C27C0BE2372014100EA73F9 /* TableSectionStyleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C27C0A92372008900EA73F9 /* TableSectionStyleTests.swift */; };
6C27C0BF2372014100EA73F9 /* TableSectionsValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C27C0AA2372008900EA73F9 /* TableSectionsValidationTests.swift */; };
6C27C0C02372014100EA73F9 /* TableSectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C27C0AB2372008900EA73F9 /* TableSectionTests.swift */; };
6C27C0EB237338BF00EA73F9 /* DataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C27C0EA237338BF00EA73F9 /* DataSourcePrefetching.swift */; };
6C27C0EE2373390300EA73F9 /* DataPrefetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C27C0EC237338DC00EA73F9 /* DataPrefetchTests.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -109,6 +111,8 @@
6C27C0AA2372008900EA73F9 /* TableSectionsValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableSectionsValidationTests.swift; sourceTree = "<group>"; };
6C27C0AB2372008900EA73F9 /* TableSectionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableSectionTests.swift; sourceTree = "<group>"; };
6C27C0B6237200DC00EA73F9 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
6C27C0EA237338BF00EA73F9 /* DataSourcePrefetching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataSourcePrefetching.swift; sourceTree = "<group>"; };
6C27C0EC237338DC00EA73F9 /* DataPrefetchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataPrefetchTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -153,6 +157,7 @@
6C27C04A2371FFB000EA73F9 /* FunctionalTableData */ = {
isa = PBXGroup;
children = (
6C27C0EA237338BF00EA73F9 /* DataSourcePrefetching.swift */,
6C27C06E2372007A00EA73F9 /* BackgroundViewProvider.swift */,
6C27C0702372007A00EA73F9 /* CellActions.swift */,
6C27C0712372007A00EA73F9 /* CellConfigType.swift */,
Expand Down Expand Up @@ -181,6 +186,7 @@
6C27C0552371FFB000EA73F9 /* FunctionalTableDataTests */ = {
isa = PBXGroup;
children = (
6C27C0EC237338DC00EA73F9 /* DataPrefetchTests.swift */,
6C27C0A72372008900EA73F9 /* CellStyleTests.swift */,
6C27C0A42372008900EA73F9 /* FunctionalDataTests.swift */,
6C27C0A82372008900EA73F9 /* FunctionalTableDataDelegateTests.swift */,
Expand Down Expand Up @@ -367,6 +373,7 @@
6C27C0862372007B00EA73F9 /* UICollectionView+Reusable.swift in Sources */,
6C27C09A2372007B00EA73F9 /* FunctionalTableData+Cells.swift in Sources */,
6C27C0842372007B00EA73F9 /* ObjCExceptionRethrower.swift in Sources */,
6C27C0EB237338BF00EA73F9 /* DataSourcePrefetching.swift in Sources */,
6C27C0872372007B00EA73F9 /* FunctionalCollectionData.swift in Sources */,
6C27C0932372007B00EA73F9 /* TableSection.swift in Sources */,
6C27C0A12372007B00EA73F9 /* Separator.swift in Sources */,
Expand All @@ -389,6 +396,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6C27C0EE2373390300EA73F9 /* DataPrefetchTests.swift in Sources */,
6C27C0BD2372014100EA73F9 /* TableSectionChangeSetTests.swift in Sources */,
6C27C0BA2372014100EA73F9 /* FunctionalTableDataDelegateTests.swift in Sources */,
6C27C0BC2372014100EA73F9 /* TableCellReuseTests.swift in Sources */,
Expand Down
10 changes: 9 additions & 1 deletion Sources/FunctionalTableData/CellActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ public struct CellActions {
/// - parameter context: The instance of `UIViewControllerPreviewing` that is participating in the 3D-touch
public typealias PreviewingViewControllerAction = (_ cell: UIView, _ point: CGPoint, _ context: UIViewControllerPreviewing) -> UIViewController?

/// A closure that returns an `Operation` to run as the cells prefetch action.
public typealias PrefetchAction = () -> Operation

/// The action to perform when the cell will be selected.
/// - Important: When the `canSelectAction` is called, it is passed a `CanSelectCallback` closure. It is the responsibility of the action to eventually call the passed in closure providing either a `true` or `false` value to it. This passed in value determines if the selection will be performed or not.
public var canSelectAction: CanSelectAction?
Expand Down Expand Up @@ -163,6 +166,8 @@ public struct CellActions {
/// - note: By default the `UIViewControllerPreviewing` will have its `sourceRect` configured to be the entire cells frame.
/// The given `previewingViewControllerAction` however can override this as it sees fit.
public var previewingViewControllerAction: PreviewingViewControllerAction?
/// The prefetch action to run when the `UITableView` or `UICollectionView` request it.
public var prefetchAction: PrefetchAction?

public init(
canSelectAction: CanSelectAction? = nil,
Expand All @@ -173,7 +178,9 @@ public struct CellActions {
canPerformAction: CanPerformAction? = nil,
canBeMoved: Bool = false,
visibilityAction: VisibilityAction? = nil,
previewingViewControllerAction: PreviewingViewControllerAction? = nil) {
previewingViewControllerAction: PreviewingViewControllerAction? = nil,
prefetchAction: PrefetchAction? = nil
) {
self.canSelectAction = canSelectAction
self.selectionAction = selectionAction
self.deselectionAction = deselectionAction
Expand All @@ -190,6 +197,7 @@ public struct CellActions {
} else {
self.previewingViewControllerAction = nil
}
self.prefetchAction = prefetchAction
}

internal var hasEditActions: Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ public class FunctionalCollectionData {
private let renderAndDiffQueue: OperationQueue
private let name: String

let dataSource: DataSource
let delegate: Delegate
private let dataSource: DataSource
internal let delegate: Delegate
private let prefetchingDataSource: DataSourcePrefetching

/// Enclosing `UICollectionView` that presents all the `TableSection` data.
///
Expand All @@ -44,6 +45,9 @@ public class FunctionalCollectionData {
guard let collectionView = collectionView else { return }
collectionView.dataSource = dataSource
collectionView.delegate = delegate
if #available(iOS 10.0, *) {
collectionView.prefetchDataSource = prefetchingDataSource
}
}
}

Expand Down Expand Up @@ -84,6 +88,7 @@ public class FunctionalCollectionData {

self.dataSource = DataSource(data: data)
self.delegate = Delegate(data: data)
self.prefetchingDataSource = DataSourcePrefetching(data: data)
}

/// Returns the cell identified by a key path.
Expand Down Expand Up @@ -292,6 +297,8 @@ public class FunctionalCollectionData {
}
}

prefetchingDataSource.invalidatePrefetch(changes: changes, newSections: localSections)

collectionView.performBatchUpdates({
data.sections = localSections
applyTableSectionChanges(changes)
Expand Down
210 changes: 210 additions & 0 deletions Sources/FunctionalTableData/DataSourcePrefetching.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//
// FunctionalTableData+UITableViewDataSourcePrefetching.swift
// FunctionalTableData
//
// Created by Geoffrey Foster on 2019-03-25.
// Copyright © 2019 Shopify. All rights reserved.
//

import UIKit

/// A custom implementation of the `UITableViewDataSourcePrefetching` and `UICollectionViewDataSourcePrefetching` protocols that hooks into the `CellAction` type and uses its `prefetchAction: PrefetchAction?`
class DataSourcePrefetching: NSObject, UITableViewDataSourcePrefetching, UICollectionViewDataSourcePrefetching {
private struct PrefetchOperationWrapper {
let observations: (finished: NSKeyValueObservation, cancelled: NSKeyValueObservation)
let operation: Operation

init(operation: Operation, observations: (finished: NSKeyValueObservation, cancelled: NSKeyValueObservation)) {
self.operation = operation
self.observations = observations
}
}

private let queue = OperationQueue()

private var operations: [ItemPath: PrefetchOperationWrapper] = [:]
private var prefetched: Set<ItemPath> = []
private let operationsLock = NSLock()

private var operationItemPathsToPrefetch: Set<ItemPath> = []
private var operationItemPathsToCancel: Set<ItemPath> = []

private let data: TableData

private let delay: (prefetch: TimeInterval, cancel: TimeInterval)

var isSuspended: Bool = false {
didSet {
guard oldValue != isSuspended else { return }

if isSuspended {
queue.cancelAllOperations()
operationsLock.withLock {
operationItemPathsToPrefetch.removeAll()
operationItemPathsToCancel.removeAll()
operations.removeAll()
}
}
queue.isSuspended = isSuspended
}
}

init(data: TableData, delay: (prefetch: TimeInterval, cancel: TimeInterval) = (0.1, 0.1)) {
self.data = data
self.delay = delay
}

func invalidate(itemPaths: [ItemPath]) {
operationsLock.withLock {
prefetched.subtract(itemPaths)
operationItemPathsToPrefetch.subtract(itemPaths)
operationItemPathsToCancel.subtract(itemPaths)
itemPaths.forEach {
operations[$0]?.operation.cancel()
}
}
}

func invalidate(sections: Set<String>) {
var itemPaths: Set<ItemPath> = []
operationsLock.withLock {
itemPaths.formUnion(prefetched.filter { sections.contains($0.sectionKey) })
itemPaths.formUnion(operationItemPathsToPrefetch.filter { sections.contains($0.sectionKey) })
itemPaths.formUnion(operationItemPathsToPrefetch.filter { sections.contains($0.sectionKey) })
}
invalidate(itemPaths: Array<ItemPath>(itemPaths))
}

// MARK: - UITableViewDataSourcePrefetching

func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
perform(.prefetch, indexPaths: indexPaths)
}

func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
perform(.cancel, indexPaths: indexPaths)
}

// MARK: - UICollectionViewDataSourcePrefetching

func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
perform(.prefetch, indexPaths: indexPaths)
}

func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
perform(.cancel, indexPaths: indexPaths)
}

// MARK: -

private enum PrefetchOperationKind {
case prefetch
case cancel
}

private func perform(_ kind: PrefetchOperationKind, indexPaths: [IndexPath]) {
// Because we can get rapid fire cancel/prefetch calls that contain the same IndexPath values we queue up ones to be cancelled and delay the cancel and queue up the fetches and delay the fetch
let itemPaths = data.sections.itemPaths(from: indexPaths)

let performDelay: TimeInterval
switch kind {
case .prefetch:
operationItemPathsToPrefetch.formUnion(itemPaths)
operationItemPathsToCancel.subtract(itemPaths)
performDelay = delay.prefetch
case .cancel:
operationItemPathsToCancel.formUnion(itemPaths)
operationItemPathsToPrefetch.subtract(itemPaths)
performDelay = delay.cancel
}

NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(run), object: nil)
perform(#selector(run), with: nil, afterDelay: performDelay)
}

private func registerOperation(_ operation: Operation, forItemPath itemPath: ItemPath) -> Operation {
func operationFinished(for itemPath: ItemPath, successfully: Bool) {
operationsLock.withLock {
_ = operations.removeValue(forKey: itemPath)
if successfully {
prefetched.insert(itemPath)
}
}
}

let finishedObservation = operation.observe(\.isFinished, options: .new) { (operation, change) in
if change.newValue == true {
operationFinished(for: itemPath, successfully: true)
}
}
let cancelledObservation = operation.observe(\.isCancelled, options: .new) { (operation, change) in
if change.newValue == true {
operationFinished(for: itemPath, successfully: false)
}
}
let prefetchOperation = PrefetchOperationWrapper(operation: operation, observations: (finished: finishedObservation, cancelled: cancelledObservation))

operationsLock.withLock {
operations[itemPath] = prefetchOperation
}

return operation
}

@objc private func run() {
/// Returns `true` if and only if an operation for the given `ItemPath` doesn't already exist and also hasn't already been run.
///
/// - Parameter itemPath:
/// - Returns: `true` if a prefetch operation should be created and run for the given `ItemPath`.
func shouldPrefetch(itemPath: ItemPath) -> Bool {
return operationsLock.withLock {
operations[itemPath] == nil && !prefetched.contains(itemPath)
}
}

var newOperations: [Operation] = []
for itemPath in operationItemPathsToPrefetch where shouldPrefetch(itemPath: itemPath) {
guard let prefetchAction = data.sections[itemPath]?.actions.prefetchAction else { continue }
let operation = prefetchAction()
newOperations.append(registerOperation(operation, forItemPath: itemPath))
}

let cancelledOperations = operationsLock.withLock {
operations.filter { operationItemPathsToCancel.contains($0.key) }.map { $0.value }
}

operationItemPathsToPrefetch.removeAll()
operationItemPathsToCancel.removeAll()

queue.addOperations(newOperations, waitUntilFinished: false)
cancelledOperations.forEach { $0.operation.cancel() }
}
}

extension DataSourcePrefetching {
func invalidatePrefetch(changes: TableSectionChangeSet, newSections: [TableSection]) {
// Certain changes should result in the prefetcher invalidating the ItemPath's that it is running/has run/will run
// This is true when:
// - a cell was deleted, should no longer run its prefetch if it was queued up
// - a cell was reloaded, the cell type or data may have changed
// - a cell is being updated, this means a state change on the cell occurred, and that state change may mean that its existing or previously run prefetch is no longer valid
var invalidatedItemPaths: [ItemPath] = []
invalidatedItemPaths.append(contentsOf: data.sections.itemPaths(from: changes.deletedRows))
invalidatedItemPaths.append(contentsOf: data.sections.itemPaths(from: changes.reloadedRows))
invalidatedItemPaths.append(contentsOf: newSections.itemPaths(from: changes.updates.map { $0.index }))
invalidate(itemPaths: invalidatedItemPaths)

// A whole section deleted means cancelling all prefetches for it so retrieve the section key for each deleted one
var invalidatedSections: Set<String> = []
invalidatedSections.formUnion(changes.deletedSections.map { data.sections[$0].key })
invalidate(sections: invalidatedSections)
}
}

private extension NSLocking {
func withLock<T> (_ body: () throws -> T) rethrows -> T {
self.lock()
defer { self.unlock() }
return try body()
}
}
13 changes: 12 additions & 1 deletion Sources/FunctionalTableData/Extensions/Array+TableSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,22 @@ extension Array where Element: TableSectionType {
return nil
}

func itemPath(from indexPath: IndexPath) -> ItemPath {
func itemPath(from indexPath: IndexPath) -> ItemPath? {
guard indexPath.section < count else { return nil }
let section = self[indexPath.section]
guard indexPath.row < section.rows.count else { return nil }
let row = section.rows[indexPath.row]
return ItemPath(sectionKey: section.key, itemKey: row.key)
}

func itemPaths(from indexPaths: [IndexPath]) -> [ItemPath] {
return indexPaths.compactMap { itemPath(from: $0) }
}

subscript(itemPath: ItemPath) -> CellConfigType? {
guard let indexPath = indexPath(from: itemPath) else { return nil }
return self[indexPath.section].rows[indexPath.row]
}
}

private extension Array where Element: Hashable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ extension FunctionalTableData {
}

let highlightRow = cellStyler.highlightedRow
let keyPath = data.sections.itemPath(from: indexPath)
guard let keyPath = data.sections.itemPath(from: indexPath) else { return nil }

if let canSelectAction = cellConfig.actions.canSelectAction {
let canSelectResult: (Bool) -> Void = { selected in
Expand Down
Loading

0 comments on commit 911e56a

Please sign in to comment.