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 Apr 18, 2019
1 parent e2d2964 commit 670b8ef
Show file tree
Hide file tree
Showing 7 changed files with 288 additions and 4 deletions.
14 changes: 11 additions & 3 deletions FunctionalTableData.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@
4CD535031F9E3A010041A3F9 /* CellStyleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD535021F9E3A010041A3F9 /* CellStyleTests.swift */; };
6C421788224D42C500D64AE2 /* ItemPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C421787224D42C500D64AE2 /* ItemPath.swift */; };
6C507AF62249268900D04521 /* FunctionalTableData+Cells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C507AF52249268900D04521 /* FunctionalTableData+Cells.swift */; };
6C507AF82249635A00D04521 /* DataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C507AF72249635A00D04521 /* DataSourcePrefetching.swift */; };
6C5F34C12231B91C00D57BEA /* ScrollViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5F34C02231B91C00D57BEA /* ScrollViewDelegate.swift */; };
6C5F34C52232D14B00D57BEA /* FunctionalTableData+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5F34C42232D14B00D57BEA /* FunctionalTableData+UITableViewDelegate.swift */; };
6C5F34C72232D15500D57BEA /* FunctionalTableData+UITableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5F34C62232D15500D57BEA /* FunctionalTableData+UITableViewDataSource.swift */; };
6C5F34C92232E8D300D57BEA /* FunctionalCollectionData+UICollectionViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5F34C82232E8D300D57BEA /* FunctionalCollectionData+UICollectionViewDelegate.swift */; };
6C5F34CB2232E99000D57BEA /* FunctionalCollectionData+UICollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C5F34CA2232E99000D57BEA /* FunctionalCollectionData+UICollectionViewDataSource.swift */; };
6C910BA922529B390000D7E9 /* FunctionalTableDataDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C910BA822529B390000D7E9 /* FunctionalTableDataDelegateTests.swift */; };
6CDA9009225C250600FF17D1 /* DataPrefetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C910BEE22554F810000D7E9 /* DataPrefetchTests.swift */; };
9FF97DB3212CA23B006FA047 /* TableCellReuseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF97DB2212CA23B006FA047 /* TableCellReuseTests.swift */; };
BC8C5D4121763B7B00443E28 /* BackgroundViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8C5D4021763B7B00443E28 /* BackgroundViewProvider.swift */; };
/* End PBXBuildFile section */
Expand Down Expand Up @@ -132,12 +134,14 @@
4CD535021F9E3A010041A3F9 /* CellStyleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellStyleTests.swift; sourceTree = "<group>"; };
6C421787224D42C500D64AE2 /* ItemPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPath.swift; sourceTree = "<group>"; };
6C507AF52249268900D04521 /* FunctionalTableData+Cells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FunctionalTableData+Cells.swift"; sourceTree = "<group>"; };
6C507AF72249635A00D04521 /* DataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourcePrefetching.swift; sourceTree = "<group>"; };
6C5F34C02231B91C00D57BEA /* ScrollViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewDelegate.swift; sourceTree = "<group>"; };
6C5F34C42232D14B00D57BEA /* FunctionalTableData+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FunctionalTableData+UITableViewDelegate.swift"; sourceTree = "<group>"; };
6C5F34C62232D15500D57BEA /* FunctionalTableData+UITableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FunctionalTableData+UITableViewDataSource.swift"; sourceTree = "<group>"; };
6C5F34C82232E8D300D57BEA /* FunctionalCollectionData+UICollectionViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FunctionalCollectionData+UICollectionViewDelegate.swift"; sourceTree = "<group>"; };
6C5F34CA2232E99000D57BEA /* FunctionalCollectionData+UICollectionViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FunctionalCollectionData+UICollectionViewDataSource.swift"; sourceTree = "<group>"; };
6C910BA822529B390000D7E9 /* FunctionalTableDataDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionalTableDataDelegateTests.swift; sourceTree = "<group>"; };
6C910BEE22554F810000D7E9 /* DataPrefetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataPrefetchTests.swift; sourceTree = "<group>"; };
9FF97DB2212CA23B006FA047 /* TableCellReuseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableCellReuseTests.swift; sourceTree = "<group>"; };
BC8C5D4021763B7B00443E28 /* BackgroundViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundViewProvider.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -216,12 +220,13 @@
children = (
4C7A270C1F2FA0F800360E9B /* Info.plist */,
4CD535021F9E3A010041A3F9 /* CellStyleTests.swift */,
6C910BEE22554F810000D7E9 /* DataPrefetchTests.swift */,
4C924F251F98E7A3005D2F02 /* FunctionalDataTests.swift */,
6C910BA822529B390000D7E9 /* FunctionalTableDataDelegateTests.swift */,
9FF97DB2212CA23B006FA047 /* TableCellReuseTests.swift */,
4CA356731F322D7F0081BE90 /* TableSectionChangeSetTests.swift */,
4CA356741F322D7F0081BE90 /* TableSectionStyleTests.swift */,
36C9208C20D3EB7500DA4251 /* TableSectionsValidationTests.swift */,
9FF97DB2212CA23B006FA047 /* TableCellReuseTests.swift */,
6C910BA822529B390000D7E9 /* FunctionalTableDataDelegateTests.swift */,
);
path = FunctionalTableDataTests;
sourceTree = "<group>";
Expand All @@ -233,6 +238,7 @@
4C7A276F1F2FB55D00360E9B /* CellActions.swift */,
4C7A27701F2FB55D00360E9B /* CellConfigType.swift */,
4C7A27761F2FB55D00360E9B /* CellStyle.swift */,
6C507AF72249635A00D04521 /* DataSourcePrefetching.swift */,
4C7A27721F2FB55D00360E9B /* HostCell.swift */,
6C421787224D42C500D64AE2 /* ItemPath.swift */,
4C7A27731F2FB55D00360E9B /* ObjCExceptionRethrower.swift */,
Expand Down Expand Up @@ -266,9 +272,9 @@
4CCCE83F1F8AA7B200C73258 /* CollectionCell.swift */,
4CCCE8421F8AA7B200C73258 /* CollectionItemConfigType.swift */,
4CCCE8411F8AA7B200C73258 /* FunctionalCollectionData.swift */,
6C5F34CA2232E99000D57BEA /* FunctionalCollectionData+UICollectionViewDataSource.swift */,
6C5F34C82232E8D300D57BEA /* FunctionalCollectionData+UICollectionViewDelegate.swift */,
4CCCE8401F8AA7B200C73258 /* UICollectionView+Reusable.swift */,
6C5F34CA2232E99000D57BEA /* FunctionalCollectionData+UICollectionViewDataSource.swift */,
);
path = CollectionView;
sourceTree = "<group>";
Expand Down Expand Up @@ -474,6 +480,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
6C507AF82249635A00D04521 /* DataSourcePrefetching.swift in Sources */,
6C5F34C12231B91C00D57BEA /* ScrollViewDelegate.swift in Sources */,
4C7A278B1F2FB89400360E9B /* UIView+Extensions.swift in Sources */,
4CCCE8481F8AA7F400C73258 /* TableItemLayout.swift in Sources */,
Expand Down Expand Up @@ -513,6 +520,7 @@
files = (
4CA356761F322D7F0081BE90 /* TableSectionStyleTests.swift in Sources */,
4CA356751F322D7F0081BE90 /* TableSectionChangeSetTests.swift in Sources */,
6CDA9009225C250600FF17D1 /* DataPrefetchTests.swift in Sources */,
4C924F261F98E7A3005D2F02 /* FunctionalDataTests.swift in Sources */,
4CD535031F9E3A010041A3F9 /* CellStyleTests.swift in Sources */,
36C9208D20D3EB7500DA4251 /* TableSectionsValidationTests.swift in Sources */,
Expand Down
10 changes: 9 additions & 1 deletion 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 let canSelectAction: CanSelectAction?
Expand All @@ -161,6 +164,8 @@ public struct CellActions {
public let visibilityAction: VisibilityAction?
/// The action to perform when the cell is 3D touched by the user.
public let 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 @@ -171,7 +176,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 @@ -181,6 +188,7 @@ public struct CellActions {
self.canBeMoved = canBeMoved
self.visibilityAction = visibilityAction
self.previewingViewControllerAction = previewingViewControllerAction
self.prefetchAction = prefetchAction
}

internal var hasEditActions: Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class FunctionalCollectionData {
didSet {
dataSource.sections = sections
delegate.sections = sections
prefetchingDataSource.sections = sections
}
}
private static let reloadEntireTableThreshold = 20
Expand All @@ -37,6 +38,7 @@ public class FunctionalCollectionData {

let dataSource = DataSource()
let delegate = Delegate()
private let prefetchingDataSource = DataSourcePrefetching()

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

Expand Down
152 changes: 152 additions & 0 deletions FunctionalTableData/DataSourcePrefetching.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//
// FunctionalTableData+UITableViewDataSourcePrefetching.swift
// FunctionalTableData
//
// Created by Geoffrey Foster on 2019-03-25.
// Copyright © 2019 Shopify. All rights reserved.
//

import Foundation

/// 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> = []

var sections: [TableSection] = []

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

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

// 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 = 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 = 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() }
}
}

private extension NSLocking {
func withLock<T> (_ body: () throws -> T) rethrows -> T {
self.lock()
defer { self.unlock() }
return try body()
}
}
9 changes: 9 additions & 0 deletions FunctionalTableData/Extensions/Array+TableSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ extension Array where Element: TableSectionType {
let row = section.rows[indexPath.row]
return ItemPath(sectionKey: section.key, itemKey: row.key)
}

func itemPaths(from indexPaths: [IndexPath]) -> [ItemPath] {
return indexPaths.map { 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
5 changes: 5 additions & 0 deletions FunctionalTableData/TableView/FunctionalTableData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public class FunctionalTableData {
cellStyler.sections = sections
dataSource.sections = sections
delegate.sections = sections
prefetchingDataSource.sections = sections
}
}
private static let reloadEntireTableThreshold = 20
Expand All @@ -60,6 +61,7 @@ public class FunctionalTableData {
private let cellStyler: CellStyler
private let dataSource: DataSource
internal let delegate: Delegate
private let prefetchingDataSource = DataSourcePrefetching()

/// Enclosing `UITableView` that presents all the `TableSection` data.
///
Expand All @@ -70,6 +72,9 @@ public class FunctionalTableData {
guard let tableView = tableView else { return }
tableView.dataSource = dataSource
tableView.delegate = delegate
if #available(iOSApplicationExtension 10.0, *) {
tableView.prefetchDataSource = prefetchingDataSource
}
tableView.rowHeight = UITableView.automaticDimension
tableView.tableFooterView = UIView(frame: .zero)
tableView.separatorStyle = .none
Expand Down
Loading

0 comments on commit 670b8ef

Please sign in to comment.