Skip to content

Commit

Permalink
Reusability hint to improve reuse performance when composite componen…
Browse files Browse the repository at this point in the history
…ts are present.
  • Loading branch information
andersio committed Apr 9, 2019
1 parent d846848 commit 7916f46
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 18 deletions.
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() }
}
75 changes: 73 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) {}

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

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

var reuseIdentifier: String {
var combiner = ReusabilityHintCombiner(root: self)
makeReusabilityHint(using: &combiner)
return combiner.generate()
}
}
23 changes: 23 additions & 0 deletions Bento/Renderable/ReusabilityHintCombiner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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 {
return types.map(fullyQualifiedTypeName(of:)).joined(separator: ",")
}
}


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: 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
115 changes: 115 additions & 0 deletions BentoTests/ReusabilityHintCombinerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import XCTest
import UIKit
import Nimble
@testable import Bento

final class ReusabilityHintCombinerTests: XCTestCase {
func test_combine_A() {
let expectedSymbol = "BentoTests.ComponentA"

let combiner = ReusabilityHintCombiner(root: ComponentA())
let symbol = combiner.generate()

expect(symbol) == String(reflecting: ComponentA.self)
expect(symbol) == expectedSymbol
}

func test_combine_AB() {
let expectedSymbol = "BentoTests.ComponentA,BentoTests.ComponentB"

var combiner = ReusabilityHintCombiner(root: ComponentA())
combiner.combine(ComponentB())
let symbol = combiner.generate()

expect(symbol) == [
String(reflecting: ComponentA.self),
String(reflecting: ComponentB.self)
].joined(separator: ",")
expect(symbol) == expectedSymbol
}

func test_combine_BA() {
let expectedSymbol = "BentoTests.ComponentB,BentoTests.ComponentA"

var combiner = ReusabilityHintCombiner(root: ComponentB())
combiner.combine(ComponentA())
let symbol = combiner.generate()

expect(symbol) == [
String(reflecting: ComponentB.self),
String(reflecting: ComponentA.self)
].joined(separator: ",")
expect(symbol) == expectedSymbol
}

func test_combine_ABC() {
let expectedSymbol = "BentoTests.ComponentA,BentoTests.ComponentB,BentoTests.ComponentC"

var combiner = ReusabilityHintCombiner(root: ComponentA())
combiner.combine(ComponentB())
combiner.combine(ComponentC())
let symbol = combiner.generate()

expect(symbol) == [
String(reflecting: ComponentA.self),
String(reflecting: ComponentB.self),
String(reflecting: ComponentC.self)
].joined(separator: ",")
expect(symbol) == expectedSymbol
}

func test_reuseIdentifier_single() {
let expectedSymbol = "BentoTests.ComponentA"
let symbol = ComponentA().reuseIdentifier

expect(symbol) == String(reflecting: ComponentA.self)
expect(symbol) == expectedSymbol
}

func test_reuseIdentifier_containerOfAB() {
let expectedSymbol = "BentoTests.ContainerOfAB,BentoTests.ComponentA,BentoTests.ComponentB"
let symbol = ContainerOfAB().reuseIdentifier

expect(symbol) == [
String(reflecting: ContainerOfAB.self),
String(reflecting: ComponentA.self),
String(reflecting: ComponentB.self)
].joined(separator: ",")
expect(symbol) == expectedSymbol
}

func test_reuseIdentifier_containerOfContainerOfAB() {
let expectedSymbol = "BentoTests.ContainerOfContainerOfAB,BentoTests.ContainerOfAB,BentoTests.ComponentA,BentoTests.ComponentB"
let symbol = ContainerOfContainerOfAB().reuseIdentifier

expect(symbol) == [
String(reflecting: ContainerOfContainerOfAB.self),
String(reflecting: ContainerOfAB.self),
String(reflecting: ComponentA.self),
String(reflecting: ComponentB.self)
].joined(separator: ",")
expect(symbol) == expectedSymbol
}
}

private protocol ReusabilityHintRenderable: Renderable {}
extension ReusabilityHintRenderable {
func render(in view: UIView) {}
}

// NOTE: Marked as internal so that the fully qualified type name (needed by a test assertion) does not depend on the
// source location.
internal struct ComponentA: ReusabilityHintRenderable {}
internal struct ComponentB: ReusabilityHintRenderable {}
internal struct ComponentC: ReusabilityHintRenderable {}
internal struct ContainerOfAB: ReusabilityHintRenderable {
internal func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {
combiner.combine(ComponentA())
combiner.combine(ComponentB())
}
}
internal struct ContainerOfContainerOfAB: ReusabilityHintRenderable {
func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {
combiner.combine(ContainerOfAB())
}
}

0 comments on commit 7916f46

Please sign in to comment.