Skip to content

Commit 9c7e8b3

Browse files
authored
Merge pull request #212 from ndtp/fullscreen-tweaks
Bug fixes and performance improvements for the ParallelPixelProcessor
2 parents 51d2049 + aa8221b commit 9c7e8b3

File tree

21 files changed

+400
-158
lines changed

21 files changed

+400
-158
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
- https://github.com/ndtp/android-testify/pull/212 - Bug fixes and performance improvements for the ParallelPixelProcessor
6+
- Add parallelThreads extension property to the Gradle plugin. This allows for customization of the number of worker threads to be used by the ParallelProcessor. Set limits on the thread pool to a minimum of 1 and a maximum of 4.
7+
- Refactor the ParallelPixelProcessor and introduce a new configuration class to wrap the thread configuration variables and the CoroutineDispatcher configuration.
8+
- Several small improvements to the FuzzyCompare method to perform fewer allocations inside the analyze function
9+
- Upgrade UiAutomator dependency to 2.3.0 https://developer.android.com/jetpack/androidx/releases/test-uiautomator
10+
- Recycle the bitmaps in the finalize block of assertSame()
11+
- Add several new tests and enhancements to the existing ParallelProcessor tests
12+
- Upgrade the compile SDK for the samples to 34
513
- https://github.com/ndtp/android-testify/pull/208 - Redefine plugin artifact to work with gradle plugin DSL
614
- https://github.com/ndtp/android-testify/pull/201 - Added ScreenshotScenarioRule which works in conjunction with Android's ActivityScenario.
715
- Added tests demonstrating the usage of ScreenshotScenarioRule.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2024 ndtp
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22+
* THE SOFTWARE.
23+
*/
24+
package dev.testify.core.processor
25+
26+
import android.graphics.Bitmap
27+
import android.graphics.Bitmap.Config.ARGB_8888
28+
import kotlinx.coroutines.ExecutorCoroutineDispatcher
29+
import org.junit.Assert.assertEquals
30+
import org.junit.Assert.assertNotNull
31+
import org.junit.Assert.assertTrue
32+
import org.junit.Test
33+
34+
class BitmapExtensionsTest {
35+
36+
private val bitmap = Bitmap.createBitmap(8, 8, ARGB_8888)
37+
38+
private fun getSubject(
39+
configuration: ParallelProcessorConfiguration
40+
) = ParallelPixelProcessor
41+
.create(configuration)
42+
.baseline(bitmap)
43+
.current(bitmap)
44+
45+
private val ParallelProcessorConfiguration.poolSize: Int
46+
get() {
47+
val dispatcher = (_executorDispatcher as ExecutorCoroutineDispatcher).executor.toString()
48+
return dispatcher.substringAfter("pool size = ").substringBefore(",").toInt()
49+
}
50+
51+
@Test
52+
fun default_thread_configuration() {
53+
val configuration = ParallelProcessorConfiguration()
54+
55+
assertEquals(4, configuration.numberOfAvailableCores)
56+
assertEquals(4, configuration.maxNumberOfChunkThreads)
57+
assertEquals(4, configuration.threadPoolSize)
58+
getSubject(configuration).analyze { _, _, _ -> true }
59+
assertNotNull(configuration._executorDispatcher)
60+
assertEquals(4, configuration.poolSize)
61+
}
62+
63+
@Test
64+
fun default_thread_configuration_visits_all_cells() {
65+
val configuration = ParallelProcessorConfiguration()
66+
val visits = Array(8) { Array(8) { false } }
67+
68+
getSubject(configuration).analyze { _, _, (x, y) ->
69+
visits[x][y] = true
70+
true
71+
}
72+
73+
assertTrue(visits.all { row -> row.all { col -> col } })
74+
assertEquals(4, configuration.poolSize)
75+
}
76+
77+
@Test
78+
fun minimum_thread_configuration_visits_all_cells() {
79+
val configuration = ParallelProcessorConfiguration(requestedNumberOfChunkThreads = 1)
80+
81+
val visits = Array(8) { Array(8) { false } }
82+
83+
getSubject(configuration).analyze { _, _, (x, y) ->
84+
visits[x][y] = true
85+
true
86+
}
87+
88+
assertTrue(visits.all { row -> row.all { col -> col } })
89+
assertEquals(1, configuration.poolSize)
90+
}
91+
92+
@Test
93+
fun large_thread_configuration_visits_all_cells() {
94+
val configuration = ParallelProcessorConfiguration(requestedNumberOfChunkThreads = 8)
95+
96+
val visits = Array(8) { Array(8) { false } }
97+
98+
getSubject(configuration).analyze { _, _, (x, y) ->
99+
visits[x][y] = true
100+
true
101+
}
102+
103+
assertTrue(visits.all { row -> row.all { col -> col } })
104+
assertEquals(8, configuration.poolSize)
105+
}
106+
}

Library/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
<meta-data
1717
android:name="dev.testify.recordMode"
1818
android:value="@string/isRecordMode" />
19+
20+
<meta-data
21+
android:name="dev.testify.parallelThreads"
22+
android:value="@string/parallelThreads" />
1923
</application>
2024

2125
</manifest>

Library/src/main/java/dev/testify/core/logic/AssertSame.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ package dev.testify.core.logic
2626
import android.app.Activity
2727
import android.content.Context
2828
import android.content.Intent
29+
import android.graphics.Bitmap
2930
import android.view.View
3031
import androidx.annotation.VisibleForTesting
3132
import androidx.test.platform.app.InstrumentationRegistry
@@ -104,6 +105,8 @@ internal fun <TActivity : Activity> assertSame(
104105
}
105106

106107
var activity: TActivity? = null
108+
var currentBitmap: Bitmap? = null
109+
var baselineBitmap: Bitmap? = null
107110

108111
try {
109112
activity = activityProvider.getActivity()
@@ -121,7 +124,7 @@ internal fun <TActivity : Activity> assertSame(
121124
configuration.beforeScreenshot(rootView)
122125
screenshotLifecycleHost.notifyObservers { it.beforeScreenshot(activity) }
123126

124-
val currentBitmap = takeScreenshot(
127+
currentBitmap = takeScreenshot(
125128
activity,
126129
outputFileName,
127130
screenshotView,
@@ -139,7 +142,7 @@ internal fun <TActivity : Activity> assertSame(
139142

140143
val destination = getDestination(activity, outputFileName)
141144

142-
val baselineBitmap = loadBaselineBitmapForComparison(testContext, description.name)
145+
baselineBitmap = loadBaselineBitmapForComparison(testContext, description.name)
143146
?: if (isRecordMode) {
144147
TestInstrumentationRegistry.instrumentationPrintln(
145148
"\n\t" + "Recording baseline for ${description.name}".cyan()
@@ -192,6 +195,8 @@ internal fun <TActivity : Activity> assertSame(
192195
}
193196
}
194197
} finally {
198+
currentBitmap?.recycle()
199+
baselineBitmap?.recycle()
195200
activity?.let { ResourceWrapper.afterTestFinished(activity) }
196201
configuration.afterTestFinished()
197202
TestifyFeatures.reset()

Library/src/main/java/dev/testify/core/processor/BitmapExtentions.kt

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* The MIT License (MIT)
33
*
4-
* Copyright (c) 2023 ndtp
4+
* Copyright (c) 2023-2024 ndtp
55
*
66
* Permission is hereby granted, free of charge, to any person obtaining a copy
77
* of this software and associated documentation files (the "Software"), to deal
@@ -25,7 +25,8 @@ package dev.testify.core.processor
2525

2626
import android.graphics.Bitmap
2727
import androidx.annotation.VisibleForTesting
28-
import androidx.annotation.VisibleForTesting.PACKAGE_PRIVATE
28+
import dev.testify.internal.helpers.ManifestPlaceholder
29+
import dev.testify.internal.helpers.getMetaDataValue
2930
import kotlinx.coroutines.CoroutineDispatcher
3031
import kotlinx.coroutines.asCoroutineDispatcher
3132
import java.util.concurrent.Executors
@@ -43,29 +44,68 @@ fun ParallelPixelProcessor.TransformResult.createBitmap(): Bitmap {
4344
}
4445

4546
/**
46-
* Cache the number of processor cores available for parallel processing.
47+
* Container class that holds the configuration for the parallel pixel processor
48+
*
49+
* @param requestedNumberOfChunkThreads - Override the number of threads to use for parallel processing.
50+
* Used for testing.
51+
*
52+
* @see [ParallelPixelProcessor]
4753
*/
48-
private val numberOfAvailableCores = Runtime.getRuntime().availableProcessors()
54+
class ParallelProcessorConfiguration(
55+
private val requestedNumberOfChunkThreads: Int? = null
56+
) {
57+
/**
58+
* Gets the number of threads to create in the parallel pixel processor thread pool.
59+
* The number of threads can be set in the `testify` closure in your build.gradle.
60+
*
61+
* build.gradle:
62+
*
63+
* testify {
64+
* parallelThreads 2
65+
* }
66+
*
67+
* If parallelThreads is not set then defaults to the numberOfAvailableCores.
68+
* If parallelThreads is less than 1, then defaults numberOfAvailableCores.
69+
* Minimum is 1.
70+
* Maximum is 4.
71+
*/
72+
val threadPoolSize: Int by lazy {
73+
val maxThreads: String? = ManifestPlaceholder.ParallelThreads.getMetaDataValue()
74+
(maxThreads?.toIntOrNull()?.takeIf { it > 0 } ?: numberOfAvailableCores).coerceIn(
75+
minimumValue = 1,
76+
maximumValue = 4
77+
)
78+
}
4979

50-
/**
51-
* The maximum number of threads to use for parallel processing.
52-
* This value is set to the number of available cores by default.
53-
* This value can be overridden for testing purposes.
54-
*/
55-
@VisibleForTesting(otherwise = PACKAGE_PRIVATE)
56-
internal var maxNumberOfChunkThreads = numberOfAvailableCores
80+
/**
81+
* Cache the number of processor cores available for parallel processing.
82+
*/
83+
val numberOfAvailableCores: Int by lazy {
84+
Runtime.getRuntime().availableProcessors()
85+
}
5786

58-
/**
59-
* The [CoroutineDispatcher] to use for parallel processing.
60-
* This value is set to a [Executors.newFixedThreadPool] with the number of available cores by default.
61-
* This value can be overridden for testing purposes.
62-
*/
63-
@Suppress("ObjectPropertyName")
64-
@VisibleForTesting
65-
internal var _executorDispatcher: CoroutineDispatcher? = null
66-
internal val executorDispatcher by lazy {
67-
if (_executorDispatcher == null) {
68-
_executorDispatcher = Executors.newFixedThreadPool(numberOfAvailableCores).asCoroutineDispatcher()
87+
/**
88+
* The maximum number of threads to use for parallel processing.
89+
* This value is set to the number of available cores by default.
90+
* This value can be overridden for testing purposes.
91+
*
92+
* To customize the thread pool, @see [threadPoolSize]
93+
*/
94+
val maxNumberOfChunkThreads: Int
95+
get() = requestedNumberOfChunkThreads ?: threadPoolSize
96+
97+
/**
98+
* The [CoroutineDispatcher] to use for parallel processing.
99+
* This value is set to a [Executors.newFixedThreadPool] with the number of available cores by default.
100+
* This value can be overridden for testing purposes.
101+
*/
102+
@Suppress("PropertyName")
103+
@VisibleForTesting
104+
internal var _executorDispatcher: CoroutineDispatcher? = null
105+
val executorDispatcher: CoroutineDispatcher by lazy {
106+
if (_executorDispatcher == null) {
107+
_executorDispatcher = Executors.newFixedThreadPool(maxNumberOfChunkThreads).asCoroutineDispatcher()
108+
}
109+
_executorDispatcher!!
69110
}
70-
_executorDispatcher!!
71111
}

Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* The MIT License (MIT)
33
*
4-
* Copyright (c) 2023 ndtp
4+
* Copyright (c) 2023-2024 ndtp
55
*
66
* Permission is hereby granted, free of charge, to any person obtaining a copy
77
* of this software and associated documentation files (the "Software"), to deal
@@ -34,17 +34,21 @@ import java.nio.IntBuffer
3434
import java.util.BitSet
3535
import kotlin.math.ceil
3636

37+
typealias AnalyzePixelFunction = (baselinePixel: Int, currentPixel: Int, position: Pair<Int, Int>) -> Boolean
38+
3739
/**
3840
* A class that allows for parallel processing of pixels in a bitmap.
3941
*
4042
* Uses coroutines to process pixels in two [Bitmap] objects in parallel.
4143
* Used by [BitmapComparator] to compare two bitmaps in parallel.
4244
* Used by [BitmapTransformer] to transform two bitmaps in parallel.
4345
*/
44-
class ParallelPixelProcessor private constructor() {
46+
class ParallelPixelProcessor private constructor(
47+
private val configuration: ParallelProcessorConfiguration
48+
) {
4549

46-
private var baselineBitmap: Bitmap? = null
47-
private var currentBitmap: Bitmap? = null
50+
private lateinit var baselineBitmap: Bitmap
51+
private lateinit var currentBitmap: Bitmap
4852

4953
/**
5054
* Set the [Bitmap] to use as the baseline.
@@ -66,19 +70,17 @@ class ParallelPixelProcessor private constructor() {
6670
* Prepare the bitmaps for parallel processing.
6771
*/
6872
private fun prepareBuffers(): ImageBuffers {
69-
val width = currentBitmap!!.width
70-
val height = currentBitmap!!.height
73+
val width = currentBitmap.width
74+
val height = currentBitmap.height
7175

7276
return ImageBuffers(
7377
width = width,
7478
height = height,
7579
baselineBuffer = IntBuffer.allocate(width * height),
7680
currentBuffer = IntBuffer.allocate(width * height)
7781
).apply {
78-
baselineBitmap!!.copyPixelsToBuffer(baselineBuffer)
79-
currentBitmap!!.copyPixelsToBuffer(currentBuffer)
80-
baselineBitmap = null
81-
currentBitmap = null
82+
baselineBitmap.copyPixelsToBuffer(baselineBuffer)
83+
currentBitmap.copyPixelsToBuffer(currentBuffer)
8284
}
8385
}
8486

@@ -87,7 +89,7 @@ class ParallelPixelProcessor private constructor() {
8789
*/
8890
private fun getChunkData(width: Int, height: Int): ChunkData {
8991
val size = width * height
90-
val chunkSize = (size / maxNumberOfChunkThreads).coerceAtLeast(1)
92+
val chunkSize = (size / configuration.maxNumberOfChunkThreads).coerceAtLeast(1)
9193
val chunks = ceil(size.toFloat() / chunkSize.toFloat()).toInt()
9294
return ChunkData(size, chunks, chunkSize)
9395
}
@@ -97,7 +99,7 @@ class ParallelPixelProcessor private constructor() {
9799
*/
98100
private fun runBlockingInChunks(chunkData: ChunkData, fn: CoroutineScope.(chunk: Int, index: Int) -> Boolean) {
99101
runBlocking {
100-
launch(executorDispatcher) {
102+
launch(configuration.executorDispatcher) {
101103
(0 until chunkData.chunks).map { chunk ->
102104
async {
103105
val start = chunk * chunkData.wholeChunkSize
@@ -134,7 +136,7 @@ class ParallelPixelProcessor private constructor() {
134136
* @param analyzer The analyzer function to call for each pixel.
135137
* @return True if all pixels pass the analyzer function, false otherwise.
136138
*/
137-
fun analyze(analyzer: (baselinePixel: Int, currentPixel: Int, position: Pair<Int, Int>) -> Boolean): Boolean {
139+
fun analyze(analyzer: AnalyzePixelFunction): Boolean {
138140
val (width, height, baselineBuffer, currentBuffer) = prepareBuffers()
139141

140142
val chunkData = getChunkData(width, height)
@@ -234,8 +236,10 @@ class ParallelPixelProcessor private constructor() {
234236
/**
235237
* Factory method to create a new [ParallelPixelProcessor].
236238
*/
237-
fun create(): ParallelPixelProcessor {
238-
return ParallelPixelProcessor()
239+
fun create(
240+
configuration: ParallelProcessorConfiguration
241+
): ParallelPixelProcessor {
242+
return ParallelPixelProcessor(configuration)
239243
}
240244
}
241245
}

0 commit comments

Comments
 (0)