From 86da5ebd56f144419a8514f6d87702e1a4587e34 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Tue, 9 Apr 2019 18:40:04 +0100 Subject: [PATCH] Reusability hint to improve reuse performance when composite components are present. --- Bento.xcodeproj/project.pbxproj | 8 ++ Bento/Adapters/CollectionViewAdapter.swift | 4 +- Bento/Adapters/TableViewAdapter.swift | 6 +- Bento/Renderable/AnyRenderable.swift | 15 ++- Bento/Renderable/Renderable.swift | 79 +++++++++++- .../Renderable/ReusabilityHintCombiner.swift | 27 ++++ Bento/Views/BentoReusableView.swift | 6 +- BentoTests/AnyRenderableTests.swift | 6 +- BentoTests/ReusabilityHintCombinerTests.swift | 115 ++++++++++++++++++ 9 files changed, 247 insertions(+), 19 deletions(-) create mode 100644 Bento/Renderable/ReusabilityHintCombiner.swift create mode 100644 BentoTests/ReusabilityHintCombinerTests.swift diff --git a/Bento.xcodeproj/project.pbxproj b/Bento.xcodeproj/project.pbxproj index ace90d9..126fb70 100644 --- a/Bento.xcodeproj/project.pbxproj +++ b/Bento.xcodeproj/project.pbxproj @@ -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 */; }; @@ -209,6 +211,8 @@ 65E3ECAB2113591500869DF3 /* FocusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableView.swift; sourceTree = ""; }; 65E3ECAD2113594600869DF3 /* BentoCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BentoCollectionView.swift; sourceTree = ""; }; 65E3ECAF2113598700869DF3 /* UIKit+BentoCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKit+BentoCollectionView.swift"; sourceTree = ""; }; + 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHintCombiner.swift; sourceTree = ""; }; + 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHintCombinerTests.swift; sourceTree = ""; }; 740921B520ACDDDA00B59F5C /* IfTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IfTests.swift; sourceTree = ""; }; 740921B720ACE5EC00B59F5C /* ConcatenationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcatenationTests.swift; sourceTree = ""; }; 74208FA22083B1F00062CC8D /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -431,6 +435,7 @@ 65E3ECA521133C9400869DF3 /* UIKit+CollectionViewFocus.swift */, 582D9986217F87B100C67B0D /* ComponentLifecycleAware.swift */, 65020C312203186400DC8F42 /* NativeView.swift */, + 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift */, ); path = Renderable; sourceTree = ""; @@ -448,6 +453,7 @@ 5830C5E621F22DDC0029044B /* ComponentLifecycleAware.swift */, 651E75BF221005E300130866 /* UIKitContainerDiffApplicationTests.swift */, 58FC4421207CF29F00DA3614 /* Info.plist */, + 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift */, ); path = BentoTests; sourceTree = ""; @@ -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 */, @@ -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 */, diff --git a/Bento/Adapters/CollectionViewAdapter.swift b/Bento/Adapters/CollectionViewAdapter.swift index bbe692e..d72ae4b 100644 --- a/Bento/Adapters/CollectionViewAdapter.swift +++ b/Bento/Adapters/CollectionViewAdapter.swift @@ -48,7 +48,7 @@ open class CollectionViewAdapterBase @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 @@ -62,7 +62,7 @@ open class CollectionViewAdapterBase 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, diff --git a/Bento/Adapters/TableViewAdapter.swift b/Bento/Adapters/TableViewAdapter.swift index 38bad4f..c7ef02d 100644 --- a/Bento/Adapters/TableViewAdapter.swift +++ b/Bento/Adapters/TableViewAdapter.swift @@ -49,7 +49,7 @@ open class TableViewAdapterBase @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) @@ -233,9 +233,9 @@ open class TableViewAdapterBase } 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) diff --git a/Bento/Renderable/AnyRenderable.swift b/Bento/Renderable/AnyRenderable.swift index dac4ad9..ccc7413 100644 --- a/Bento/Renderable/AnyRenderable.swift +++ b/Bento/Renderable/AnyRenderable.swift @@ -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: Base) { @@ -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(to type: T.Type) -> T? { return base.cast(to: type) } @@ -88,6 +86,10 @@ class AnyRenderableBox: AnyRenderableBoxBase { base.render(in: view as! Base.View) } + override func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) { + base.makeReusabilityHint(using: &combiner) + } + override func cast(to type: T.Type) -> T? { if let anyRenderable = base as? AnyRenderable { return anyRenderable.cast(to: type) @@ -106,5 +108,6 @@ class AnyRenderableBoxBase { return AnyRenderable(self) } func render(in view: UIView) { fatalError() } + func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) { fatalError() } func cast(to type: T.Type) -> T? { fatalError() } } diff --git a/Bento/Renderable/Renderable.swift b/Bento/Renderable/Renderable.swift index 17cad35..7fd5418 100644 --- a/Bento/Renderable/Renderable.swift +++ b/Bento/Renderable/Renderable.swift @@ -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) } @@ -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() + } +} diff --git a/Bento/Renderable/ReusabilityHintCombiner.swift b/Bento/Renderable/ReusabilityHintCombiner.swift new file mode 100644 index 0000000..399088d --- /dev/null +++ b/Bento/Renderable/ReusabilityHintCombiner.swift @@ -0,0 +1,27 @@ +public struct ReusabilityHintCombiner { + internal var types: [Any.Type] + + internal init(root: R) { + types = [root.componentType] + } + + public mutating func combine(_ component: R) { + types.append(component.componentType) + component.makeReusabilityHint(using: &self) + } + + __consuming func generate() -> String { + 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) +} diff --git a/Bento/Views/BentoReusableView.swift b/Bento/Views/BentoReusableView.swift index 0a36817..2a1043d 100644 --- a/Bento/Views/BentoReusableView.swift +++ b/Bento/Views/BentoReusableView.swift @@ -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) { renderingView = view } else { renderingView = component.viewType.generate() diff --git a/BentoTests/AnyRenderableTests.swift b/BentoTests/AnyRenderableTests.swift index dbd5d42..f6e4934 100644 --- a/BentoTests/AnyRenderableTests.swift +++ b/BentoTests/AnyRenderableTests.swift @@ -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 @@ -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) { diff --git a/BentoTests/ReusabilityHintCombinerTests.swift b/BentoTests/ReusabilityHintCombinerTests.swift new file mode 100644 index 0000000..f6183aa --- /dev/null +++ b/BentoTests/ReusabilityHintCombinerTests.swift @@ -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()) + } +}