Skip to content

Commit

Permalink
Bk/optimize view reuse (#313)
Browse files Browse the repository at this point in the history
* Optimize view reuse

* Use ObjectIdentifier for view differentiator

* Update tests

* Update CHANGELOG.md
  • Loading branch information
bryankeller authored Aug 6, 2024
1 parent e19a229 commit b6ea9f8
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 280 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- Rewrote accessibility code to avoid posting notifications, which causes poor Voice Over performance and odd focus bugs
- Rewrote `ItemViewReuseManager` to perform fewer set operations, improving CPU usage by ~15% when scrolling quickly on an iPhone XR

## [v2.0.0](https://github.com/airbnb/HorizonCalendar/compare/v1.16.0...v2.0.0) - 2023-12-19

Expand Down
159 changes: 44 additions & 115 deletions Sources/Internal/ItemViewReuseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,139 +23,68 @@ final class ItemViewReuseManager {

// MARK: Internal

func viewsForVisibleItems(
_ visibleItems: Set<VisibleItem>,
recycleUnusedViews: Bool,
viewHandler: (
ItemView,
VisibleItem,
_ previousBackingVisibleItem: VisibleItem?,
_ isReusedViewSameAsPreviousView: Bool)
-> Void)
func reusedViewContexts(
visibleItems: Set<VisibleItem>,
reuseUnusedViews: Bool)
-> [ReusedViewContext]
{
var visibleItemsDifferencesItemViewDifferentiators = [
_CalendarItemViewDifferentiator: Set<VisibleItem>
]()

// For each reuse ID, track the difference between the new set of visible items and the previous
// set of visible items. The remaining previous visible items after subtracting the current
// visible items are the previously visible items that aren't currently visible, and are
// therefore free to be reused.
var contexts = [ReusedViewContext]()

var previousViewsForVisibleItems = viewsForVisibleItems
viewsForVisibleItems.removeAll(keepingCapacity: true)

for visibleItem in visibleItems {
let differentiator = visibleItem.calendarItemModel._itemViewDifferentiator
let viewDifferentiator = visibleItem.calendarItemModel._itemViewDifferentiator

var visibleItemsDifference: Set<VisibleItem>
if let difference = visibleItemsDifferencesItemViewDifferentiators[differentiator] {
visibleItemsDifference = difference
} else if
let previouslyVisibleItems = visibleItemsForItemViewDifferentiators[differentiator]
let context: ReusedViewContext =
if let view = previousViewsForVisibleItems.removeValue(forKey: visibleItem)
{
visibleItemsDifference = previouslyVisibleItems.subtracting(visibleItems)
ReusedViewContext(
view: view,
visibleItem: visibleItem,
isViewReused: true,
isReusedViewSameAsPreviousView: true)
} else if !(unusedViewsForViewDifferentiators[viewDifferentiator]?.isEmpty ?? true) {
ReusedViewContext(
view: unusedViewsForViewDifferentiators[viewDifferentiator]!.remove(at: 0),
visibleItem: visibleItem,
isViewReused: true,
isReusedViewSameAsPreviousView: false)
} else {
visibleItemsDifference = []
ReusedViewContext(
view: ItemView(initialCalendarItemModel: visibleItem.calendarItemModel),
visibleItem: visibleItem,
isViewReused: false,
isReusedViewSameAsPreviousView: false)
}

let context = reusedViewContext(
for: visibleItem,
recycleUnusedViews: recycleUnusedViews,
unusedPreviouslyVisibleItems: &visibleItemsDifference)
viewHandler(
context.view,
visibleItem,
context.previousBackingVisibleItem,
context.isReusedViewSameAsPreviousView)

visibleItemsDifferencesItemViewDifferentiators[differentiator] = visibleItemsDifference
}
}

// MARK: Private

private var visibleItemsForItemViewDifferentiators = [
_CalendarItemViewDifferentiator: Set<VisibleItem>
]()
private var viewsForVisibleItems = [VisibleItem: ItemView]()

private func reusedViewContext(
for visibleItem: VisibleItem,
recycleUnusedViews: Bool,
unusedPreviouslyVisibleItems: inout Set<VisibleItem>)
-> ReusedViewContext
{
let differentiator = visibleItem.calendarItemModel._itemViewDifferentiator

let view: ItemView
let previousBackingVisibleItem: VisibleItem?
let isReusedViewSameAsPreviousView: Bool

if let previouslyVisibleItems = visibleItemsForItemViewDifferentiators[differentiator] {
if previouslyVisibleItems.contains(visibleItem) {
// New visible item was also an old visible item, so we can just use the same view again.

guard let previousView = viewsForVisibleItems[visibleItem] else {
preconditionFailure("""
`viewsForVisibleItems` must have a key for every member in
`visibleItemsForItemViewDifferentiators`'s values.
""")
}
contexts.append(context)

view = previousView
previousBackingVisibleItem = visibleItem
isReusedViewSameAsPreviousView = true
viewsForVisibleItems[visibleItem] = context.view
}

visibleItemsForItemViewDifferentiators[differentiator]?.remove(visibleItem)
viewsForVisibleItems.removeValue(forKey: visibleItem)
} else {
if recycleUnusedViews, let previouslyVisibleItem = unusedPreviouslyVisibleItems.first {
// An unused, previously-visible item is available, so reuse it.

guard let previousView = viewsForVisibleItems[previouslyVisibleItem] else {
preconditionFailure("""
`viewsForVisibleItems` must have a key for every member in
`visibleItemsForItemViewDifferentiators`'s values.
""")
}

view = previousView
previousBackingVisibleItem = previouslyVisibleItem
isReusedViewSameAsPreviousView = false

unusedPreviouslyVisibleItems.remove(previouslyVisibleItem)

visibleItemsForItemViewDifferentiators[differentiator]?.remove(previouslyVisibleItem)
viewsForVisibleItems.removeValue(forKey: previouslyVisibleItem)
} else {
// No previously-visible item is available for reuse (or view recycling is disabled), so
// create a new view.
view = ItemView(initialCalendarItemModel: visibleItem.calendarItemModel)
previousBackingVisibleItem = nil
isReusedViewSameAsPreviousView = false
}
if reuseUnusedViews {
for (visibleItem, unusedView) in previousViewsForVisibleItems {
let viewDifferentiator = visibleItem.calendarItemModel._itemViewDifferentiator
unusedViewsForViewDifferentiators[viewDifferentiator, default: .init()].append(unusedView)
}
} else {
// No previously-visible item is available for reuse, so create a new view.
view = ItemView(initialCalendarItemModel: visibleItem.calendarItemModel)
previousBackingVisibleItem = nil
isReusedViewSameAsPreviousView = false
}

let newVisibleItems = visibleItemsForItemViewDifferentiators[differentiator] ?? []
visibleItemsForItemViewDifferentiators[differentiator] = newVisibleItems
visibleItemsForItemViewDifferentiators[differentiator]?.insert(visibleItem)
viewsForVisibleItems[visibleItem] = view

return ReusedViewContext(
view: view,
previousBackingVisibleItem: previousBackingVisibleItem,
isReusedViewSameAsPreviousView: isReusedViewSameAsPreviousView)
return contexts
}

// MARK: Private

private var viewsForVisibleItems = [VisibleItem: ItemView]()
private var unusedViewsForViewDifferentiators = [_CalendarItemViewDifferentiator: [ItemView]]()

}

// MARK: - ReusedViewContext

private struct ReusedViewContext {
struct ReusedViewContext {
let view: ItemView
let previousBackingVisibleItem: VisibleItem?
let visibleItem: VisibleItem
let isViewReused: Bool
let isReusedViewSameAsPreviousView: Bool
}
3 changes: 1 addition & 2 deletions Sources/Public/AnyCalendarItemModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ public protocol AnyCalendarItemModel {
///
/// - Note: There is no reason to create an instance of this enum from your feature code; it should only be invoked internally.
public struct _CalendarItemViewDifferentiator: Hashable {
let viewRepresentableTypeDescription: String
let viewTypeDescription: String
let viewType: ObjectIdentifier
let invariantViewProperties: AnyHashable
}
6 changes: 2 additions & 4 deletions Sources/Public/CalendarItemModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ public struct CalendarItemModel<ViewRepresentable>: AnyCalendarItemModel where
content: ViewRepresentable.Content)
{
_itemViewDifferentiator = _CalendarItemViewDifferentiator(
viewRepresentableTypeDescription: String(reflecting: ViewRepresentable.self),
viewTypeDescription: String(reflecting: ViewRepresentable.ViewType.self),
viewType: ObjectIdentifier(ViewRepresentable.self),
invariantViewProperties: invariantViewProperties)

self.invariantViewProperties = invariantViewProperties
Expand Down Expand Up @@ -115,8 +114,7 @@ extension CalendarItemModel where ViewRepresentable.Content == Never {
/// and `font`, assuming none of those values change in response to `content` updates.
public init(invariantViewProperties: ViewRepresentable.InvariantViewProperties) {
_itemViewDifferentiator = _CalendarItemViewDifferentiator(
viewRepresentableTypeDescription: String(reflecting: ViewRepresentable.self),
viewTypeDescription: String(reflecting: ViewRepresentable.ViewType.self),
viewType: ObjectIdentifier(ViewRepresentable.self),
invariantViewProperties: invariantViewProperties)

self.invariantViewProperties = invariantViewProperties
Expand Down
41 changes: 21 additions & 20 deletions Sources/Public/CalendarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -773,29 +773,30 @@ public final class CalendarView: UIView {
var viewsToHideForVisibleItems = visibleViewsForVisibleItems
visibleViewsForVisibleItems.removeAll(keepingCapacity: true)

reuseManager.viewsForVisibleItems(
visibleItems,
recycleUnusedViews: !UIAccessibility.isVoiceOverRunning,
viewHandler: { view, visibleItem, previousBackingVisibleItem, isReusedViewSameAsPreviousView in
UIView.conditionallyPerformWithoutAnimation(when: !isReusedViewSameAsPreviousView) {
if view.superview == nil {
let insertionIndex = subviewInsertionIndexTracker.insertionIndex(
forSubviewWithCorrespondingItemType: visibleItem.itemType)
scrollView.insertSubview(view, at: insertionIndex)
}

view.isHidden = false

configureView(view, with: visibleItem)
let contexts = reuseManager.reusedViewContexts(
visibleItems: visibleItems,
reuseUnusedViews: !UIAccessibility.isVoiceOverRunning)

for context in contexts {
UIView.conditionallyPerformWithoutAnimation(when: !context.isReusedViewSameAsPreviousView) {
if context.view.superview == nil {
let insertionIndex = subviewInsertionIndexTracker.insertionIndex(
forSubviewWithCorrespondingItemType: context.visibleItem.itemType)
scrollView.insertSubview(context.view, at: insertionIndex)
}

visibleViewsForVisibleItems[visibleItem] = view
context.view.isHidden = false

if let previousBackingVisibleItem {
// Don't hide views that were reused
viewsToHideForVisibleItems.removeValue(forKey: previousBackingVisibleItem)
}
})
configureView(context.view, with: context.visibleItem)
}

visibleViewsForVisibleItems[context.visibleItem] = context.view

if context.isViewReused {
// Don't hide views that were reused
viewsToHideForVisibleItems.removeValue(forKey: context.visibleItem)
}
}

// Hide any old views that weren't reused. This is faster than adding / removing subviews.
// If VoiceOver is running, we remove the view to save memory (since views aren't reused).
Expand Down
Loading

0 comments on commit b6ea9f8

Please sign in to comment.