Skip to content

Commit e0274b2

Browse files
authored
Merge pull request #177 from cashapp/entin/hit-test-snapshot-performance
Improve hit test performance by optimizing Y axis
2 parents a79b01b + bfe034f commit e0274b2

File tree

9 files changed

+138
-31
lines changed

9 files changed

+138
-31
lines changed

Example/AccessibilitySnapshot.xcodeproj/xcshareddata/xcbaselines/607FACE41AFB9204008FA782.xcbaseline/EDA548A6-E453-4CBA-9366-15D413356833.plist

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<key>com.apple.XCTPerformanceMetric_WallClockTime</key>
1212
<dict>
1313
<key>baselineAverage</key>
14-
<real>48.792810</real>
14+
<real>17.200000</real>
1515
<key>baselineIntegrationDisplayName</key>
1616
<string>Local Baseline</string>
1717
</dict>

Example/SnapshotTests/HitTargetTests.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ final class HitTargetTests: SnapshotTestCase {
4949
_ = try HitTargetSnapshotUtility.generateSnapshotImage(
5050
for: buttonTraitsViewController.view,
5151
useMonochromeSnapshot: true,
52-
viewRenderingMode: .drawHierarchyInRect
52+
viewRenderingMode: .drawHierarchyInRect,
53+
maxPermissibleMissedRegionHeight: 4
5354
)
5455
} catch {
5556
XCTFail("Utility should not fail to generate snapshot image")

Sources/AccessibilitySnapshot/Core/Swift/Classes/HitTargetSnapshotView.swift

+84-17
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,30 @@ public enum HitTargetSnapshotUtility {
2727
/// * Regions that hit test to `nil` will be darkened.
2828
/// * Regions that hit test to another view will be highlighted using one of the specified `colors`.
2929
///
30+
/// By default this snapshot is very slow (on the order of 50 seconds for a full screen snapshot) since it hit tests
31+
/// every pixel in the view to achieve a perfectly accurate result. As a performance optimization, you can trade off
32+
/// greatly increased performance for the possibility of missing very thin views by defining the maximum height of
33+
/// a missed region you are okay with missing (`maxPermissibleMissedRegionHeight`). In particular, this might miss
34+
/// views of the specified height or less which have the same hit target both above and below the view. Setting this
35+
/// value to 1 pt improves the run time by almost (1 / scale factor), i.e. a 65% improvement for a 3x scale device,
36+
/// so this trade-off is often worth it. Increasing the value from there will continue to decrease the run time, but
37+
/// you quickly get diminishing returns.
38+
///
3039
/// - parameter view: The base view to be tested against.
3140
/// - parameter useMonochromeSnapshot: Whether or not the snapshot of the `view` should be monochrome. Using a
3241
/// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to
3342
/// read certain views.
3443
/// - parameter viewRenderingMode: The rendering method to use when snapshotting the `view`.
3544
/// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order,
3645
/// repeating through the array as necessary and avoiding adjacent regions using the same color when possible.
46+
/// - parameter maxPermissibleMissedRegionHeight: The maximum height for which it is permissible to "miss" a view.
47+
/// Value must be a positive integer.
3748
public static func generateSnapshotImage(
3849
for view: UIView,
3950
useMonochromeSnapshot: Bool,
4051
viewRenderingMode: AccessibilitySnapshotView.ViewRenderingMode,
41-
colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors
52+
colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors,
53+
maxPermissibleMissedRegionHeight: CGFloat = 0
4254
) throws -> UIImage {
4355
let colors = colors.map { $0.withAlphaComponent(0.2) }
4456

@@ -56,7 +68,9 @@ public enum HitTargetSnapshotUtility {
5668
var viewToColorMap: [UIView: UIColor] = [:]
5769
let pixelWidth: CGFloat = 1 / UIScreen.main.scale
5870

59-
func drawScanLine(
71+
let maxPermissibleMissedRegionHeight = max(pixelWidth, floor(maxPermissibleMissedRegionHeight))
72+
73+
func drawScanLineSegment(
6074
for hitView: UIView?,
6175
startingAtX: CGFloat,
6276
endingAtX: CGFloat,
@@ -95,27 +109,23 @@ public enum HitTargetSnapshotUtility {
95109

96110
let touchOffset = pixelWidth / 2
97111

98-
// Step through every pixel along the Y axis.
99-
for y in stride(from: bounds.minY, to: bounds.maxY, by: pixelWidth) {
112+
typealias ScanLine = [(xRange: ClosedRange<CGFloat>, view: UIView?)]
113+
114+
func scanLine(y: CGFloat) -> ScanLine {
115+
var scanLine: ScanLine = []
100116
var lastHit: (CGFloat, UIView?)? = nil
101117

102118
// Step through every pixel along the X axis.
103119
for x in stride(from: bounds.minX, to: bounds.maxX, by: pixelWidth) {
104-
let hitView = view.hitTest(CGPoint(x: x + touchOffset, y: y + touchOffset), with: nil)
120+
let hitView = view.hitTest(CGPoint(x: x + touchOffset, y: y), with: nil)
105121

106122
if let lastHit = lastHit, hitView == lastHit.1 {
107123
// We're still hitting the same view. Keep scanning.
108124
continue
109125

110126
} else if let previousHit = lastHit {
111127
// We've moved on to a new view, so draw the scan line for the previous view.
112-
drawScanLine(
113-
for: previousHit.1,
114-
startingAtX: previousHit.0,
115-
endingAtX: x,
116-
y: y,
117-
lineHeight: pixelWidth
118-
)
128+
scanLine.append(((previousHit.0...x), previousHit.1))
119129
lastHit = (x, hitView)
120130

121131
} else {
@@ -126,15 +136,72 @@ public enum HitTargetSnapshotUtility {
126136

127137
// Finish the scan line if necessary.
128138
if let lastHit = lastHit, let lastHitView = lastHit.1 {
129-
drawScanLine(
130-
for: lastHitView,
131-
startingAtX: lastHit.0,
132-
endingAtX: bounds.maxX,
139+
scanLine.append(((lastHit.0...bounds.maxX), lastHitView))
140+
}
141+
142+
return scanLine
143+
}
144+
145+
func drawScanLine(_ scanLine: ScanLine, y: CGFloat, lineHeight: CGFloat) {
146+
for segment in scanLine {
147+
drawScanLineSegment(
148+
for: segment.view,
149+
startingAtX: segment.xRange.lowerBound,
150+
endingAtX: segment.xRange.upperBound,
133151
y: y,
134-
lineHeight: pixelWidth
152+
lineHeight: lineHeight
135153
)
136154
}
137155
}
156+
157+
func scanLinesEqual(_ a: ScanLine, _ b: ScanLine) -> Bool {
158+
return a.count == b.count
159+
&& zip(a, b).allSatisfy { aSegment, bSegment in
160+
aSegment.xRange == bSegment.xRange && aSegment.view === bSegment.view
161+
}
162+
}
163+
164+
// In some cases striding by 1/3 can result in the `to` value being included due to a floating point rouding
165+
// error, in particular when dealing with bounds with a negative y origin. By striding to a value slightly
166+
// less than the desired stop (small enough to be less than the density of any screen in the foreseeable
167+
// future), we can avoid this rounding problem.
168+
let stopEpsilon: CGFloat = 0.0001
169+
170+
// Step through every full point along the Y axis and check if it's equal to the above line. If so, draw the
171+
// line at a full point width. If not, step through the pixel lines and draw each individually.
172+
var previousScanLine: (y: CGFloat, scanLine: ScanLine)? = nil
173+
for y in stride(from: bounds.minY, to: bounds.maxY, by: maxPermissibleMissedRegionHeight) {
174+
let fullScanLine = scanLine(y: y + touchOffset)
175+
176+
if let previousScanLine = previousScanLine, scanLinesEqual(fullScanLine, previousScanLine.scanLine) {
177+
drawScanLine(
178+
previousScanLine.scanLine,
179+
y: previousScanLine.y,
180+
lineHeight: maxPermissibleMissedRegionHeight
181+
)
182+
183+
} else if let previousScanLine = previousScanLine {
184+
drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth)
185+
for lineY in stride(from: previousScanLine.y + pixelWidth, to: y - stopEpsilon, by: pixelWidth) {
186+
drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth)
187+
}
188+
189+
} else {
190+
// No-op. We'll draw this on the next iteration.
191+
}
192+
193+
previousScanLine = (y, fullScanLine)
194+
}
195+
196+
// Draw the final full scan line and any trailing pixel lines (if the bounds.height isn't divisible by the
197+
// maxPermissibleMissedRegionHeight).
198+
if let previousScanLine = previousScanLine {
199+
drawScanLine(previousScanLine.scanLine, y: previousScanLine.y, lineHeight: pixelWidth)
200+
201+
for lineY in stride(from: previousScanLine.y + pixelWidth, to: bounds.maxY, by: pixelWidth) {
202+
drawScanLine(scanLine(y: lineY + touchOffset), y: lineY, lineHeight: pixelWidth)
203+
}
204+
}
138205
}
139206
}
140207

Sources/AccessibilitySnapshot/SnapshotTesting/SnapshotTesting+Accessibility.swift

+12-1
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@ extension Snapshotting where Value == UIView, Format == UIImage {
146146
/// * Regions that hit test to `nil` will be darkened.
147147
/// * Regions that hit test to another view will be highlighted using one of the specified `colors`.
148148
///
149+
/// By default this snapshot is very slow (on the order of 50 seconds for a full screen snapshot) since it hit tests
150+
/// every pixel in the view to achieve a perfectly accurate result. As a performance optimization, you can trade off
151+
/// greatly increased performance for the possibility of missing very thin views by defining the maximum height of
152+
/// a missed region you are okay with missing (`maxPermissibleMissedRegionHeight`). In particular, this might miss
153+
/// views of the specified height or less which have the same hit target both above and below the view. Setting this
154+
/// value to 1 pt improves the run time by almost (1 / scale factor), i.e. a 65% improvement for a 3x scale device,
155+
/// so this trade-off is often worth it. Increasing the value from there will continue to decrease the run time, but
156+
/// you quickly get diminishing returns.
157+
///
149158
/// - parameter useMonochromeSnapshot: Whether or not the snapshot of the view should be monochrome. Using a
150159
/// monochrome snapshot makes it more clear where the highlighted elements are, but may make it difficult to
151160
/// read certain views.
@@ -159,6 +168,7 @@ extension Snapshotting where Value == UIView, Format == UIImage {
159168
useMonochromeSnapshot: Bool = true,
160169
drawHierarchyInKeyWindow: Bool = false,
161170
colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors,
171+
maxPermissibleMissedRegionHeight: CGFloat = 0,
162172
file: StaticString = #file,
163173
line: UInt = #line
164174
) -> Snapshotting {
@@ -177,7 +187,8 @@ extension Snapshotting where Value == UIView, Format == UIImage {
177187
for: view,
178188
useMonochromeSnapshot: useMonochromeSnapshot,
179189
viewRenderingMode: (drawHierarchyInKeyWindow ? .drawHierarchyInRect : .renderLayerInContext),
180-
colors: colors
190+
colors: colors,
191+
maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight
181192
)
182193

183194
if requiresWindow {

Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_Accessibility.h

+4-4
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,15 @@
6262
}\
6363
}
6464

65-
#define SnapshotVerifyWithHitTargets(view__, identifier__, useMonochromeSnapshot__)\
65+
#define SnapshotVerifyWithHitTargets(view__, identifier__, useMonochromeSnapshot__, maxPermissibleMissedRegionHeight__)\
6666
{\
6767
_Pragma("clang diagnostic push")\
6868
_Pragma("clang diagnostic ignored \"-Wundeclared-selector\"")\
69-
SEL selector = @selector(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:perPixelTolerance:overallTolerance:);\
69+
SEL selector = @selector(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:maxPermissibleMissedRegionHeight:perPixelTolerance:overallTolerance:);\
7070
_Pragma("clang diagnostic pop")\
71-
typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat);\
71+
typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat, CGFloat);\
7272
SnapshotMethod snapshotVerifyWithHitTargets = (SnapshotMethod)[self methodForSelector:selector];\
73-
NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, 0, 0);\
73+
NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, maxPermissibleMissedRegionHeight__, 0, 0);\
7474
if (errorDescription == nil) {\
7575
XCTAssertTrue(YES);\
7676
} else {\

Sources/AccessibilitySnapshot/iOSSnapshotTestCase/ObjC/include/FBSnapshotTestCase_ImpreciseAccessibility.h

+4-4
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,15 @@
6262
}\
6363
}
6464

65-
#define SnapshotImpreciseVerifyWithHitTargets(view__, identifier__, useMonochromeSnapshot__, perPixelTolerance__, overallTolerance__)\
65+
#define SnapshotImpreciseVerifyWithHitTargets(view__, identifier__, useMonochromeSnapshot__, maxPermissibleMissedRegionHeight__, perPixelTolerance__, overallTolerance__)\
6666
{\
6767
_Pragma("clang diagnostic push")\
6868
_Pragma("clang diagnostic ignored \"-Wundeclared-selector\"")\
69-
SEL selector = @selector(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:perPixelTolerance:overallTolerance:);\
69+
SEL selector = @selector(snapshotVerifyWithHitTargets:identifier:useMonochromeSnapshot:maxPermissibleMissedRegionHeight:perPixelTolerance:overallTolerance:);\
7070
_Pragma("clang diagnostic pop")\
71-
typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat);\
71+
typedef NSString * (*SnapshotMethod)(id, SEL, UIView *, NSString *, BOOL, CGFloat, CGFloat, CGFloat);\
7272
SnapshotMethod snapshotVerifyWithHitTargets = (SnapshotMethod)[self methodForSelector:selector];\
73-
NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, perPixelTolerance__, overallTolerance__);\
73+
NSString *errorDescription = snapshotVerifyWithInvertedColors(self, selector, view__, identifier__ ?: @"", useMonochromeSnapshot__, maxPermissibleMissedRegionHeight__, perPixelTolerance__, overallTolerance__);\
7474
if (errorDescription == nil) {\
7575
XCTAssertTrue(YES);\
7676
} else {\

Sources/AccessibilitySnapshot/iOSSnapshotTestCase/Swift/FBSnapshotTestCase+Accessibility.swift

+13
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ extension FBSnapshotTestCase {
156156
/// * Regions that hit test to `nil` will be darkened.
157157
/// * Regions that hit test to another view will be highlighted using one of the specified `colors`.
158158
///
159+
/// By default this snapshot is very slow (on the order of 50 seconds for a full screen snapshot) since it hit tests
160+
/// every pixel in the view to achieve a perfectly accurate result. As a performance optimization, you can trade off
161+
/// greatly increased performance for the possibility of missing very thin views by defining the maximum height of
162+
/// a missed region you are okay with missing (`maxPermissibleMissedRegionHeight`). In particular, this might miss
163+
/// views of the specified height or less which have the same hit target both above and below the view. Setting this
164+
/// value to 1 pt improves the run time by almost (1 / scale factor), i.e. a 65% improvement for a 3x scale device,
165+
/// so this trade-off is often worth it. Increasing the value from there will continue to decrease the run time, but
166+
/// you quickly get diminishing returns.
167+
///
159168
/// - parameter view: The view to be snapshotted.
160169
/// - parameter identifier: An optional identifier included in the snapshot name, for use when there are multiple\
161170
/// snapshot tests in a given test method. Defaults to no identifier.
@@ -164,6 +173,8 @@ extension FBSnapshotTestCase {
164173
/// read certain views.
165174
/// - parameter colors: An array of colors to use for the highlighted regions. These colors will be used in order,
166175
/// repeating through the array as necessary and avoiding adjacent regions using the same color when possible.
176+
/// - parameter maxPermissibleMissedRegionHeight: The maximum height for which it is permissible to "miss" a view.
177+
/// Value must be a positive integer.
167178
/// - parameter suffixes: NSOrderedSet object containing strings that are appended to the reference images
168179
/// directory. Defaults to `FBSnapshotTestCaseDefaultSuffixes()`.
169180
/// - parameter file: The file in which the test result should be attributed.
@@ -173,6 +184,7 @@ extension FBSnapshotTestCase {
173184
identifier: String = "",
174185
useMonochromeSnapshot: Bool = true,
175186
colors: [UIColor] = AccessibilitySnapshotView.defaultMarkerColors,
187+
maxPermissibleMissedRegionHeight: CGFloat = 0,
176188
suffixes: NSOrderedSet = FBSnapshotTestCaseDefaultSuffixes(),
177189
file: StaticString = #file,
178190
line: UInt = #line
@@ -182,6 +194,7 @@ extension FBSnapshotTestCase {
182194
identifier: identifier,
183195
useMonochromeSnapshot: useMonochromeSnapshot,
184196
colors: colors,
197+
maxPermissibleMissedRegionHeight: maxPermissibleMissedRegionHeight,
185198
suffixes: suffixes,
186199
perPixelTolerance: 0,
187200
overallTolerance: 0,

0 commit comments

Comments
 (0)