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 all commits
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 /* 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 */; };
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 /* ReusabilityHint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHint.swift; sourceTree = "<group>"; };
65E4D8B9225D11C700CA7CB3 /* ReusabilityHintTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusabilityHintTests.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 /* ReusabilityHint.swift */,
);
path = Renderable;
sourceTree = "<group>";
Expand All @@ -448,6 +453,7 @@
5830C5E621F22DDC0029044B /* ComponentLifecycleAware.swift */,
651E75BF221005E300130866 /* UIKitContainerDiffApplicationTests.swift */,
58FC4421207CF29F00DA3614 /* Info.plist */,
65E4D8B9225D11C700CA7CB3 /* ReusabilityHintTests.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 /* ReusabilityHint.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 /* ReusabilityHintTests.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(_ hint: inout ReusabilityHint) {
base.makeReusabilityHint(&hint)
}

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(_ hint: inout ReusabilityHint) {
base.makeReusabilityHint(&hint)
}

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(_ hint: inout ReusabilityHint) { fatalError() }
func cast<T>(to type: T.Type) -> T? { fatalError() }
}
80 changes: 78 additions & 2 deletions Bento/Renderable/Renderable.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,73 @@
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(_ hint: inout ReusabilityHint) {
/// children.forEach { hint.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 `ReusabilityHint.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:
/// - 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(_ hint: inout ReusabilityHint) {}

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

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

var reusabilityHint: ReusabilityHint {
var hint = ReusabilityHint(root: self)
makeReusabilityHint(&hint)
return hint
}

var reuseIdentifier: String {
return reusabilityHint.generate()
}
}
60 changes: 60 additions & 0 deletions Bento/Renderable/ReusabilityHint.swift
Original file line number Diff line number Diff line change
@@ -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<R: Renderable>(root: R) {
marks = [.node(root.componentType), .begin]
}

public mutating func combine<R: Renderable>(_ 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)
}
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.reusabilityHint.isCompatible(with: component.reusabilityHint) {
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