From 86da5ebd56f144419a8514f6d87702e1a4587e34 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Tue, 9 Apr 2019 18:40:04 +0100 Subject: [PATCH 1/2] 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()) + } +} From 73f6360d7863d3d035a3ad7c477c306308764258 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Tue, 9 Apr 2019 21:29:44 +0100 Subject: [PATCH 2/2] Change the symbol generation algorithm. Rename the struct. --- Bento.xcodeproj/project.pbxproj | 16 +-- Bento/Renderable/AnyRenderable.swift | 10 +- Bento/Renderable/Renderable.swift | 23 ++-- Bento/Renderable/ReusabilityHint.swift | 60 +++++++++ .../Renderable/ReusabilityHintCombiner.swift | 27 ---- Bento/Views/BentoReusableView.swift | 2 +- BentoTests/ReusabilityHintCombinerTests.swift | 115 ---------------- BentoTests/ReusabilityHintTests.swift | 125 ++++++++++++++++++ 8 files changed, 211 insertions(+), 167 deletions(-) create mode 100644 Bento/Renderable/ReusabilityHint.swift delete mode 100644 Bento/Renderable/ReusabilityHintCombiner.swift delete mode 100644 BentoTests/ReusabilityHintCombinerTests.swift create mode 100644 BentoTests/ReusabilityHintTests.swift diff --git a/Bento.xcodeproj/project.pbxproj b/Bento.xcodeproj/project.pbxproj index 126fb70..cc107df 100644 --- a/Bento.xcodeproj/project.pbxproj +++ b/Bento.xcodeproj/project.pbxproj @@ -82,8 +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 */; }; + 65E4D8B8225D0AC100CA7CB3 /* ReusabilityHint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHint.swift */; }; + 65E4D8BA225D11C700CA7CB3 /* ReusabilityHintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintTests.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 */; }; @@ -211,8 +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 = ""; }; + 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHint.swift; sourceTree = ""; }; + 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHintTests.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; }; @@ -435,7 +435,7 @@ 65E3ECA521133C9400869DF3 /* UIKit+CollectionViewFocus.swift */, 582D9986217F87B100C67B0D /* ComponentLifecycleAware.swift */, 65020C312203186400DC8F42 /* NativeView.swift */, - 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift */, + 65E4D8B7225D0AC100CA7CB3 /* ReusabilityHint.swift */, ); path = Renderable; sourceTree = ""; @@ -453,7 +453,7 @@ 5830C5E621F22DDC0029044B /* ComponentLifecycleAware.swift */, 651E75BF221005E300130866 /* UIKitContainerDiffApplicationTests.swift */, 58FC4421207CF29F00DA3614 /* Info.plist */, - 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift */, + 65E4D8B9225D11C700CA7CB3 /* ReusabilityHintTests.swift */, ); path = BentoTests; sourceTree = ""; @@ -741,7 +741,7 @@ 9AF4786C2120CA7500F87E21 /* BentoReusableView.swift in Sources */, 61B64BB6208745730092082C /* CollectionViewContainerCell.swift in Sources */, 65E3ECA2211317EA00869DF3 /* FocusCoordinator.swift in Sources */, - 65E4D8B8225D0AC100CA7CB3 /* ReusabilityHintCombiner.swift in Sources */, + 65E4D8B8225D0AC100CA7CB3 /* ReusabilityHint.swift in Sources */, 65E3ECAE2113594600869DF3 /* BentoCollectionView.swift in Sources */, 65E3ECAC2113591500869DF3 /* FocusableView.swift in Sources */, A9509880661501C40B50E453 /* TableViewHeaderFooterView.swift in Sources */, @@ -760,7 +760,7 @@ 58D27BA721B83B2700DC9600 /* DeletableTests.swift in Sources */, 653D460B2256665000CF3E4C /* AdapterStoreTests.swift in Sources */, 58FC4429207CF2BB00DA3614 /* TestRenderable.swift in Sources */, - 65E4D8BA225D11C700CA7CB3 /* ReusabilityHintCombinerTests.swift in Sources */, + 65E4D8BA225D11C700CA7CB3 /* ReusabilityHintTests.swift in Sources */, 5830C5E721F22DDC0029044B /* ComponentLifecycleAware.swift in Sources */, 740921B620ACDDDA00B59F5C /* IfTests.swift in Sources */, 740921B820ACE5EC00B59F5C /* ConcatenationTests.swift in Sources */, diff --git a/Bento/Renderable/AnyRenderable.swift b/Bento/Renderable/AnyRenderable.swift index ccc7413..7ab4b22 100644 --- a/Bento/Renderable/AnyRenderable.swift +++ b/Bento/Renderable/AnyRenderable.swift @@ -25,8 +25,8 @@ public struct AnyRenderable: Renderable { base.render(in: view) } - public func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) { - base.makeReusabilityHint(using: &combiner) + public func makeReusabilityHint(_ hint: inout ReusabilityHint) { + base.makeReusabilityHint(&hint) } func cast(to type: T.Type) -> T? { @@ -86,8 +86,8 @@ class AnyRenderableBox: AnyRenderableBoxBase { base.render(in: view as! Base.View) } - override func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) { - base.makeReusabilityHint(using: &combiner) + override func makeReusabilityHint(_ hint: inout ReusabilityHint) { + base.makeReusabilityHint(&hint) } override func cast(to type: T.Type) -> T? { @@ -108,6 +108,6 @@ class AnyRenderableBoxBase { return AnyRenderable(self) } func render(in view: UIView) { fatalError() } - func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) { fatalError() } + func makeReusabilityHint(_ hint: inout ReusabilityHint) { fatalError() } func cast(to type: T.Type) -> T? { fatalError() } } diff --git a/Bento/Renderable/Renderable.swift b/Bento/Renderable/Renderable.swift index 7fd5418..94ad7f1 100644 --- a/Bento/Renderable/Renderable.swift +++ b/Bento/Renderable/Renderable.swift @@ -26,8 +26,8 @@ public protocol Renderable { /// // 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) } + /// func makeReusabilityHint(_ hint: inout ReusabilityHint) { + /// children.forEach { hint.combine($0) } /// } /// } /// @@ -51,7 +51,7 @@ public protocol Renderable { /// CompositeComponent(children: [C(), B(), A()]) /// ``` /// - /// - important: The order of `combiner.combine(_:)` matters. + /// - important: The order of `ReusabilityHint.combine(_:)` matters. /// /// - important: Bento always considers the component type for view reusability. So components need not combine /// its own type again. @@ -60,12 +60,13 @@ public protocol Renderable { /// or dynamic view hierarchies. /// /// - parameters: - /// - combiner: The combiner to concatenate all relevant information that affects reusability. - func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) + /// - hint: An opaque structure which may be fed with all relevant information of which Bento should take account + /// when considering view reusability. + func makeReusabilityHint(_ hint: inout ReusabilityHint) } public extension Renderable { - func makeReusabilityHint(using combiner: inout ReusabilityHintCombiner) {} + func makeReusabilityHint(_ hint: inout ReusabilityHint) {} func asAnyRenderable() -> AnyRenderable { return AnyRenderable(self) @@ -99,13 +100,13 @@ internal extension Renderable { ?? type(of: self) } - var reusabilityHintCombiner: ReusabilityHintCombiner { - var combiner = ReusabilityHintCombiner(root: self) - makeReusabilityHint(using: &combiner) - return combiner + var reusabilityHint: ReusabilityHint { + var hint = ReusabilityHint(root: self) + makeReusabilityHint(&hint) + return hint } var reuseIdentifier: String { - return reusabilityHintCombiner.generate() + return reusabilityHint.generate() } } diff --git a/Bento/Renderable/ReusabilityHint.swift b/Bento/Renderable/ReusabilityHint.swift new file mode 100644 index 0000000..730cc1c --- /dev/null +++ b/Bento/Renderable/ReusabilityHint.swift @@ -0,0 +1,60 @@ +public struct ReusabilityHint { + internal enum Mark: Equatable { + case node(Any.Type) + case begin + case end + + static func == (lhs: Mark, rhs: Mark) -> Bool { + switch (lhs, rhs) { + case let (.node(lhs), .node(rhs)): + return lhs == rhs + case (.begin, .begin), (.end, end): + return true + default: + return false + } + } + } + + internal var marks: [Mark] + + internal init(root: R) { + marks = [.node(root.componentType), .begin] + } + + public mutating func combine(_ component: R) { + marks.append(.node(component.componentType)) + marks.append(.begin) + component.makeReusabilityHint(&self) + marks.append(.end) + } + + func generate() -> String { + // NOTE: It isn't quite important that we generate a very clean symbol. It only needs to be consistently + // reproduced. + var symbol = marks.reduce(into: "") { buffer, mark in + switch mark { + case let .node(type): + buffer += fullyQualifiedTypeName(of: type) + case .begin: + buffer += "[" + case .end: + buffer += "]" + } + } + + // NOTE: Since the root node opens with a `begin` mark, we should balance it with an `end` mark. + symbol += "]" + return symbol + } + + func isCompatible(with other: ReusabilityHint) -> Bool { + return marks == other.marks + } +} + +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/Renderable/ReusabilityHintCombiner.swift b/Bento/Renderable/ReusabilityHintCombiner.swift deleted file mode 100644 index 399088d..0000000 --- a/Bento/Renderable/ReusabilityHintCombiner.swift +++ /dev/null @@ -1,27 +0,0 @@ -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 2a1043d..f263aab 100644 --- a/Bento/Views/BentoReusableView.swift +++ b/Bento/Views/BentoReusableView.swift @@ -14,7 +14,7 @@ extension BentoReusableView { if let oldComponent = oldComponent, let view = containedView, - oldComponent.reusabilityHintCombiner.isCompatible(with: component.reusabilityHintCombiner) { + oldComponent.reusabilityHint.isCompatible(with: component.reusabilityHint) { renderingView = view } else { renderingView = component.viewType.generate() diff --git a/BentoTests/ReusabilityHintCombinerTests.swift b/BentoTests/ReusabilityHintCombinerTests.swift deleted file mode 100644 index f6183aa..0000000 --- a/BentoTests/ReusabilityHintCombinerTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -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()) - } -} diff --git a/BentoTests/ReusabilityHintTests.swift b/BentoTests/ReusabilityHintTests.swift new file mode 100644 index 0000000..4be9866 --- /dev/null +++ b/BentoTests/ReusabilityHintTests.swift @@ -0,0 +1,125 @@ +import XCTest +import UIKit +import Nimble +@testable import Bento + +final class ReusabilityHintTests: XCTestCase { + func test_combine_A() { + let expectedSymbol = "BentoTests.ComponentA[]" + + let combiner = ReusabilityHint(root: ComponentA()) + let symbol = combiner.generate() + + expect(symbol) == expectedSymbol + } + + func test_combine_AB() { + let expectedSymbol = "BentoTests.ComponentA[BentoTests.ComponentB[]]" + + var combiner = ReusabilityHint(root: ComponentA()) + combiner.combine(ComponentB()) + + let symbol = combiner.generate() + expect(symbol) == expectedSymbol + } + + func test_combine_BA() { + let expectedSymbol = "BentoTests.ComponentB[BentoTests.ComponentA[]]" + + var combiner = ReusabilityHint(root: ComponentB()) + combiner.combine(ComponentA()) + + let symbol = combiner.generate() + expect(symbol) == expectedSymbol + } + + func test_combine_ABC() { + let expectedSymbol = "BentoTests.ComponentA[BentoTests.ComponentB[]BentoTests.ComponentC[]]" + + var combiner = ReusabilityHint(root: ComponentA()) + combiner.combine(ComponentB()) + combiner.combine(ComponentC()) + + let symbol = combiner.generate() + expect(symbol) == expectedSymbol + } + + func test_should_be_compatible_to_itself() { + let hint = Container([]).reusabilityHint + + expect(hint.isCompatible(with: hint)) == true + } + + func test_should_be_compatible_to_itself_with_children() { + let hint = Container([ + ComponentA().asAnyRenderable(), + ComponentB().asAnyRenderable() + ]).reusabilityHint + + expect(hint.isCompatible(with: hint)) == true + } + + func test_trees_that_are_equal_after_flattening_should_not_be_considered_compatible() { + let lhs = Container([ + Container([ + Container([]).asAnyRenderable() + ]).asAnyRenderable() + ]).reusabilityHint + let rhs = Container([ + Container([]).asAnyRenderable(), + Container([]).asAnyRenderable() + ]).reusabilityHint + + expect(lhs.isCompatible(with: rhs)) == false + expect(lhs.generate()) != rhs.generate() + } + + func test_reuseIdentifier_single() { + let expectedSymbol = "BentoTests.ComponentA[]" + let symbol = ComponentA().reuseIdentifier + + expect(symbol) == expectedSymbol + } + + func test_reuseIdentifier_containerOfAB() { + let expectedSymbol = "BentoTests.Container[BentoTests.ComponentA[]BentoTests.ComponentB[]]" + let symbol = Container([ + ComponentA().asAnyRenderable(), + ComponentB().asAnyRenderable() + ]).reuseIdentifier + expect(symbol) == expectedSymbol + } + + func test_reuseIdentifier_containerOfContainerOfAB() { + let expectedSymbol = "BentoTests.Container[BentoTests.Container[BentoTests.ComponentA[]BentoTests.ComponentB[]]]" + let symbol = Container([ + Container([ + ComponentA().asAnyRenderable(), + ComponentB().asAnyRenderable() + ]).asAnyRenderable() + ]).reuseIdentifier + 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 Container: ReusabilityHintRenderable { + let children: [AnyRenderable] + + init(_ children: [AnyRenderable]) { + self.children = children + } + + internal func makeReusabilityHint(_ hint: inout ReusabilityHint) { + children.forEach { hint.combine($0) } + } +}