Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reusability hint to improve reuse performance when composite components are present. #146

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Bento.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@
65E3ECAC2113591500869DF3 /* FocusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E3ECAB2113591500869DF3 /* FocusableView.swift */; };
65E3ECAE2113594600869DF3 /* BentoCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E3ECAD2113594600869DF3 /* BentoCollectionView.swift */; };
65E3ECB02113598700869DF3 /* UIKit+BentoCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E3ECAF2113598700869DF3 /* UIKit+BentoCollectionView.swift */; };
65E4D8B8225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift */; };
65E4D8BA225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift */; };
740921B620ACDDDA00B59F5C /* IfTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740921B520ACDDDA00B59F5C /* IfTests.swift */; };
740921B820ACE5EC00B59F5C /* ConcatenationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740921B720ACE5EC00B59F5C /* ConcatenationTests.swift */; };
74208FA12083B1F00062CC8D /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 74208FA22083B1F00062CC8D /* Nimble.framework */; };
Expand Down Expand Up @@ -209,6 +211,8 @@
65E3ECAB2113591500869DF3 /* FocusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableView.swift; sourceTree = "<group>"; };
65E3ECAD2113594600869DF3 /* BentoCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BentoCollectionView.swift; sourceTree = "<group>"; };
65E3ECAF2113598700869DF3 /* UIKit+BentoCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+BentoCollectionView.swift"; sourceTree = "<group>"; };
65E4D8B7225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHintCombiner.swift; sourceTree = "<group>"; };
65E4D8B9225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHintCombinerTests.swift; sourceTree = "<group>"; };
740921B520ACDDDA00B59F5C /* IfTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfTests.swift; sourceTree = "<group>"; };
740921B720ACE5EC00B59F5C /* ConcatenationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcatenationTests.swift; sourceTree = "<group>"; };
74208FA22083B1F00062CC8D /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -431,6 +435,7 @@
65E3ECA521133C9400869DF3 /* UIKit+CollectionViewFocus.swift */,
582D9986217F87B100C67B0D /* ComponentLifecycleAware.swift */,
65020C312203186400DC8F42 /* NativeView.swift */,
65E4D8B7225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift */,
);
path = Renderable;
sourceTree = "<group>";
Expand All @@ -448,6 +453,7 @@
5830C5E621F22DDC0029044B /* ComponentLifecycleAware.swift */,
651E75BF221005E300130866 /* UIKitContainerDiffApplicationTests.swift */,
58FC4421207CF29F00DA3614 /* Info.plist */,
65E4D8B9225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift */,
);
path = BentoTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -735,6 +741,7 @@
9AF4786C2120CA7500F87E21 /* BentoReusableView.swift in Sources */,
61B64BB6208745730092082C /* CollectionViewContainerCell.swift in Sources */,
65E3ECA2211317EA00869DF3 /* FocusCoordinator.swift in Sources */,
65E4D8B8225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift in Sources */,
65E3ECAE2113594600869DF3 /* BentoCollectionView.swift in Sources */,
65E3ECAC2113591500869DF3 /* FocusableView.swift in Sources */,
A9509880661501C40B50E453 /* TableViewHeaderFooterView.swift in Sources */,
Expand All @@ -753,6 +760,7 @@
58D27BA721B83B2700DC9600 /* DeletableTests.swift in Sources */,
653D460B2256665000CF3E4C /* AdapterStoreTests.swift in Sources */,
58FC4429207CF2BB00DA3614 /* TestRenderable.swift in Sources */,
65E4D8BA225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift in Sources */,
5830C5E721F22DDC0029044B /* ComponentLifecycleAware.swift in Sources */,
740921B620ACDDDA00B59F5C /* IfTests.swift in Sources */,
740921B820ACE5EC00B59F5C /* ConcatenationTests.swift in Sources */,
Expand Down
4 changes: 2 additions & 2 deletions Bento/Adapters/CollectionViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ open class CollectionViewAdapterBase<SectionID: Hashable, ItemID: Hashable>
@objc(collectionView:cellForItemAtIndexPath:)
open func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let component = node(at: indexPath).component
let reuseIdentifier = component.fullyQualifiedTypeName
let reuseIdentifier = component.reuseIdentifier
collectionView.register(CollectionViewContainerCell.self, forCellWithReuseIdentifier: reuseIdentifier)
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! CollectionViewContainerCell

Expand All @@ -62,7 +62,7 @@ open class CollectionViewAdapterBase<SectionID: Hashable, ItemID: Hashable>
knownSupplements.insert(supplement)

let component = sections[indexPath.section].supplements[supplement]
let reuseIdentifier = component?.fullyQualifiedTypeName ?? emptyReuseIdentifier
let reuseIdentifier = component?.reuseIdentifier ?? emptyReuseIdentifier

collectionView.register(CollectionViewContainerReusableView.self,
forSupplementaryViewOfKind: kind,
Expand Down
6 changes: 3 additions & 3 deletions Bento/Adapters/TableViewAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ open class TableViewAdapterBase<SectionID: Hashable, ItemID: Hashable>
@objc(tableView:cellForRowAtIndexPath:)
open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let component = node(at: indexPath).component
let reuseIdentifier = component.fullyQualifiedTypeName
let reuseIdentifier = component.reuseIdentifier

guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) as? TableViewContainerCell else {
tableView.register(TableViewContainerCell.self, forCellReuseIdentifier: reuseIdentifier)
Expand Down Expand Up @@ -233,9 +233,9 @@ open class TableViewAdapterBase<SectionID: Hashable, ItemID: Hashable>
}

private func render(_ component: AnyRenderable, in tableView: UITableView) -> UIView {
guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: component.fullyQualifiedTypeName) as? TableViewHeaderFooterView else {
guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: component.reuseIdentifier) as? TableViewHeaderFooterView else {
tableView.register(TableViewHeaderFooterView.self,
forHeaderFooterViewReuseIdentifier: component.fullyQualifiedTypeName)
forHeaderFooterViewReuseIdentifier: component.reuseIdentifier)
return render(component, in: tableView)
}
header.bind(component)
Expand Down
15 changes: 9 additions & 6 deletions Bento/Renderable/AnyRenderable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ public struct AnyRenderable: Renderable {
return base.componentType
}

internal var fullyQualifiedTypeName: String {
/// NOTE: `String.init(reflecting:)` gives the fully qualified type name.
// Tests would catch unexpeced type name printing behavior due to Swift runtime changes.
return String(reflecting: componentType)
}

private let base: AnyRenderableBoxBase

public init<Base: Renderable>(_ base: Base) {
Expand All @@ -31,6 +25,10 @@ public struct AnyRenderable: Renderable {
base.render(in: view)
}

public func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {
base.makeReusabilityHint(using: &combiner)
}

func cast<T>(to type: T.Type) -> T? {
return base.cast(to: type)
}
Expand Down Expand Up @@ -88,6 +86,10 @@ class AnyRenderableBox<Base: Renderable>: AnyRenderableBoxBase {
base.render(in: view as! Base.View)
}

override func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {
base.makeReusabilityHint(using: &combiner)
}

override func cast<T>(to type: T.Type) -> T? {
if let anyRenderable = base as? AnyRenderable {
return anyRenderable.cast(to: type)
Expand All @@ -106,5 +108,6 @@ class AnyRenderableBoxBase {
return AnyRenderable(self)
}
func render(in view: UIView) { fatalError() }
func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) { fatalError() }
func cast<T>(to type: T.Type) -> T? { fatalError() }
}
79 changes: 77 additions & 2 deletions Bento/Renderable/Renderable.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,72 @@
import UIKit

/// Protocol which every Component needs to conform to.
/// - View: UIView subtype which is the top level view type of the component.
/// A type that can be used as a component in a Bento box.
public protocol Renderable {
/// The reusable view type that the component uses for rendering its content.
associatedtype View: NativeView

/// Render the content of `self` into `view`.
func render(in view: View)

/// Produce a performance hint for Bento to better decide its view reusability strategy.
///
/// Note that having a reusability hint **does not imply** that Bento would always only reuse views when the
/// reusability hint matches. In other words, you **must still ensure** your composite component view handles type
/// mismatches in the view hierarchy correctly and gracefully.
///
/// For example, if you have a composite component that has a small but fixed number of combinations of child
/// components, you may implement this requirement to improve the performance, which otherwise would involve
/// time in recreating views as they are queued to go on screen.
///
/// ```swift
/// struct CompositeComponent: Renderable {
/// let children: [AnyRenderable]
///
/// func render(in view: View) {
/// // Logic to recreate the view hierarchy if types and orders of components do not match
/// }
///
/// func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {
/// children.forEach { combiner.combine($0) }
/// }
/// }
///
/// // NOTE: These combinations would all result in different reusability hints.
/// // (Assuming there are three component types `A`, `B`, and `C`.
/// CompositeComponent(children: [])
/// CompositeComponent(children: [A()])
/// CompositeComponent(children: [B()])
/// CompositeComponent(children: [C()])
/// CompositeComponent(children: [A(), B()])
/// CompositeComponent(children: [A(), C()])
/// CompositeComponent(children: [B(), A()])
/// CompositeComponent(children: [B(), C()])
/// CompositeComponent(children: [C(), A()])
/// CompositeComponent(children: [C(), B()])
/// CompositeComponent(children: [A(), B(), C()])
/// CompositeComponent(children: [A(), C(), B()])
/// CompositeComponent(children: [B(), A(), C()])
/// CompositeComponent(children: [B(), C(), A()])
/// CompositeComponent(children: [C(), A(), B()])
/// CompositeComponent(children: [C(), B(), A()])
/// ```
///
/// - important: The order of `combiner.combine(_:)` matters.
///
/// - important: Bento always considers the component type for view reusability. So components need not combine
/// its own type again.
///
/// - note: This is an optional requirement intended for composite components that contain children components
/// or dynamic view hierarchies.
///
/// - parameters:
/// - combiner: The combiner to concatenate all relevant information that affects reusability.
func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner)
}

public extension Renderable {
func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it scan it through the hierarchy? e.g C(children: [C(children: [A()], B()]) vs C(children: [C(children: [B()], A()])?

Copy link
Contributor Author

@andersio andersio Apr 9, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn’t do so automatically. When you say combiner.combine(child), A’s part of the call it would eventually invoke makeReusabilityHint(using:) on the child. So as long as you combine every child using the combiner, and that all descendants implement this, the whole hierarchy would be walked over.

This can be made less crumblesome if we have a protocol for composite component e.g. CompositeRenderable which is also useful for auto propagation of messages like *LifecycleAware. But this can be done after this PR which lays the cornerstone.


func asAnyRenderable() -> AnyRenderable {
return AnyRenderable(self)
}
Expand All @@ -34,3 +92,20 @@ public extension Renderable {
).asAnyRenderable()
}
}

internal extension Renderable {
var componentType: Any.Type {
return (self as? AnyRenderable)?.componentType
?? type(of: self)
}

var reusabilityHintCombiner: ReusabilityHintCombiner {
var combiner = ReusabilityHintCombiner(root: self)
makeReusabilityHint(using: &combiner)
return combiner
}

var reuseIdentifier: String {
return reusabilityHintCombiner.generate()
}
}
27 changes: 27 additions & 0 deletions Bento/Renderable/ReusabilityHintCombiner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
public struct ReusabilityHintCombiner {
internal var types: [Any.Type]

internal init<R: Renderable>(root: R) {
types = [root.componentType]
}

public mutating func combine<R: Renderable>(_ component: R) {
types.append(component.componentType)
component.makeReusabilityHint(using: &self)
}

__consuming func generate() -> String {
andersio marked this conversation as resolved.
Show resolved Hide resolved
return types.map(fullyQualifiedTypeName(of:)).joined(separator: ",")
}

__consuming func isCompatible(with other: __owned ReusabilityHintCombiner) -> Bool {
return types.elementsEqual(other.types, by: ==)
}
}


func fullyQualifiedTypeName(of type: Any.Type) -> String {
/// NOTE: `String.init(reflecting:)` gives the fully qualified type name.
// Tests would catch unexpeced type name printing behavior due to Swift runtime changes.
return String(reflecting: type)
}
6 changes: 5 additions & 1 deletion Bento/Views/BentoReusableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ protocol BentoReusableView: AnyObject {

extension BentoReusableView {
func bind(_ component: AnyRenderable?) {
let oldComponent = self.component
self.component = component

if let component = component {
let renderingView: UIView

if let view = containedView, type(of: view) == component.viewType {
if let oldComponent = oldComponent,
let view = containedView,
oldComponent.reusabilityHintCombiner.isCompatible(with: component.reusabilityHintCombiner) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not usually happen, should it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All visible items hit this path when the state changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean oldComponent.reusabilityHintCombiner.isCompatible(with: component.reusabilityHintCombiner) to be false. As it'f false it means that we are recreating the view, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeh

renderingView = view
} else {
renderingView = component.viewType.generate()
Expand Down
6 changes: 1 addition & 5 deletions BentoTests/AnyRenderableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ class AnyRenderableTests: XCTestCase {

expect(renderable.viewType) === TestView.self
expect(renderable.componentType) === TestRenderable.self
expect(renderable.fullyQualifiedTypeName) == String(reflecting: TestRenderable.self)
expect(renderable.fullyQualifiedTypeName) == "BentoTests.TestRenderable"

let view = renderable.viewType.generate()
expect(type(of: view)) === TestView.self
Expand All @@ -62,9 +60,7 @@ internal class TestView: UIView {
var hasInvoked = false
}

// NOTE: Marked as internal so that the fully qualified type name (needed by a test assertion) does not depend on the
// source location.
internal final class TestRenderable: Renderable {
private final class TestRenderable: Renderable {
let renderAction: (TestView) -> Void

init(render: @escaping (TestView) -> Void) {
Expand Down
Loading