Skip to content

Commit 07e3b52

Browse files
committed
Move threading concerns to API surface
The scanning implementation is making decisions on threading that aren't optimal: 1) It scans for view roots from the calling thread 2) But then it always performs the scanning of each hierarchy from the main thread (but only if they're Android views). There's one post to the main thread per Android root view and each gets 5 seconds for the traversal, so if the main thread is blocked and we have N windows we could wait N * 5 seconds. This change moves these decisions around threading behavior to the callers, with a default provided configuration that matches the previous behavior, except there is a single post and the root scanning is done as part of it.
1 parent e7bdb77 commit 07e3b52

4 files changed

Lines changed: 132 additions & 31 deletions

File tree

radiography/api/radiography.api

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,20 @@ public final class radiography/Radiography {
1212
public static final fun scan (Lradiography/ScanScope;)Ljava/lang/String;
1313
public static final fun scan (Lradiography/ScanScope;Ljava/util/List;)Ljava/lang/String;
1414
public static final fun scan (Lradiography/ScanScope;Ljava/util/List;Lradiography/ViewFilter;)Ljava/lang/String;
15-
public static synthetic fun scan$default (Lradiography/ScanScope;Ljava/util/List;Lradiography/ViewFilter;ILjava/lang/Object;)Ljava/lang/String;
15+
public static final fun scan (Lradiography/ScanScope;Ljava/util/List;Lradiography/ViewFilter;Lradiography/ScanExecutor;)Ljava/lang/String;
16+
public static synthetic fun scan$default (Lradiography/ScanScope;Ljava/util/List;Lradiography/ViewFilter;Lradiography/ScanExecutor;ILjava/lang/Object;)Ljava/lang/String;
17+
}
18+
19+
public abstract interface class radiography/ScanExecutor {
20+
public abstract fun execute (Ljava/util/concurrent/Callable;)Ljava/lang/String;
21+
}
22+
23+
public final class radiography/ScanExecutors {
24+
public static final field INSTANCE Lradiography/ScanExecutors;
25+
public static final field PassthroughExecutor Lradiography/ScanExecutor;
26+
public static final fun HandlerPostingExecutor (Landroid/os/Handler;JLjava/util/concurrent/TimeUnit;)Lradiography/ScanExecutor;
27+
public static final fun LooperEnforcingExecutor (Landroid/os/Looper;)Lradiography/ScanExecutor;
28+
public static final fun NeverThrowingExecutor (Lradiography/ScanExecutor;)Lradiography/ScanExecutor;
1629
}
1730

1831
public abstract interface class radiography/ScanScope {

radiography/src/main/java/radiography/Radiography.kt

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
package radiography
22

3-
import android.os.Handler
4-
import android.os.Looper
53
import android.view.View
64
import android.view.WindowManager
75
import androidx.annotation.VisibleForTesting
86
import radiography.Radiography.scan
7+
import radiography.ScanExecutors.HandlerPostingExecutor
8+
import radiography.ScanExecutors.NeverThrowingExecutor
9+
import radiography.ScanExecutors.mainHandler
910
import radiography.ScanScopes.AllWindowsScope
1011
import radiography.ScannableView.AndroidView
1112
import radiography.ViewStateRenderers.DefaultsNoPii
1213
import radiography.internal.renderTreeString
13-
import java.util.concurrent.CountDownLatch
1414
import java.util.concurrent.TimeUnit.SECONDS
1515

1616
/**
@@ -42,37 +42,24 @@ public object Radiography {
4242
public fun scan(
4343
scanScope: ScanScope = AllWindowsScope,
4444
viewStateRenderers: List<ViewStateRenderer> = DefaultsNoPii,
45-
viewFilter: ViewFilter = ViewFilters.NoFilter
46-
): String = buildString {
47-
val roots = try {
48-
scanScope.findRoots()
49-
} catch (e: Throwable) {
50-
append("Exception when finding scan roots: ${e.message}")
51-
return@buildString
52-
}
53-
54-
roots.forEach { scanRoot ->
55-
// The entire view tree is single threaded, and that's typically the main thread, but
56-
// it doesn't have to be, and we don't know where the passed in view is coming from.
57-
val viewLooper = (scanRoot as? AndroidView)?.view?.handler?.looper
58-
?: Looper.getMainLooper()!!
59-
60-
if (viewLooper.thread == Thread.currentThread()) {
61-
scanFromLooperThread(scanRoot, viewStateRenderers, viewFilter)
62-
} else {
63-
val latch = CountDownLatch(1)
64-
Handler(viewLooper).post {
65-
scanFromLooperThread(scanRoot, viewStateRenderers, viewFilter)
66-
latch.countDown()
67-
}
68-
if (!latch.await(5, SECONDS)) {
69-
return "Could not retrieve view hierarchy from main thread after 5 seconds wait"
70-
}
45+
viewFilter: ViewFilter = ViewFilters.NoFilter,
46+
scanExecutor: ScanExecutor = NeverThrowingExecutor(
47+
HandlerPostingExecutor(
48+
mainHandler,
49+
5,
50+
SECONDS
51+
)
52+
)
53+
): String = scanExecutor.execute {
54+
buildString {
55+
val roots = scanScope.findRoots()
56+
roots.forEach { scanRoot ->
57+
scanRoot(scanRoot, viewStateRenderers, viewFilter)
7158
}
7259
}
7360
}
7461

75-
private fun StringBuilder.scanFromLooperThread(
62+
private fun StringBuilder.scanRoot(
7663
rootView: ScannableView,
7764
viewStateRenderers: List<ViewStateRenderer>,
7865
viewFilter: ViewFilter
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package radiography
2+
3+
import java.util.concurrent.Callable
4+
5+
/**
6+
* Ensures that scanning happens on the right thread (by e.g. posting work, throwing, or simply
7+
* not checking, depending on implementation)
8+
*
9+
* Some commons executors are:
10+
* - [ScanExecutors.NeverThrowingExecutor]
11+
* - [ScanExecutors.PassthroughExecutor]
12+
* - [ScanExecutors.HandlerPostingExecutor]
13+
* - [ScanExecutors.LooperEnforcingExecutor]
14+
*/
15+
public fun interface ScanExecutor {
16+
17+
/** Returns the result of executing [callable] */
18+
public fun execute(callable: Callable<String>): String
19+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package radiography
2+
3+
import android.os.Handler
4+
import android.os.Looper
5+
import java.util.concurrent.CountDownLatch
6+
import java.util.concurrent.TimeUnit
7+
8+
public object ScanExecutors {
9+
10+
internal val mainHandler by lazy {
11+
Handler(Looper.getMainLooper())
12+
}
13+
14+
/**
15+
* Runs the scanning tasks synchronously without any thread check, posting or exception catching.
16+
*/
17+
@JvmField
18+
public val PassthroughExecutor: ScanExecutor = ScanExecutor { callable ->
19+
callable.call()
20+
}
21+
22+
/**
23+
* Executes the scanning tasks on the provided [delegate] ScanExecutor but catches any
24+
* exception and returns an error string instead.
25+
*/
26+
@JvmStatic
27+
public fun NeverThrowingExecutor(delegate: ScanExecutor): ScanExecutor = ScanExecutor { callable ->
28+
try {
29+
delegate.execute(callable)
30+
} catch (throwable: Throwable) {
31+
"Exception when scanning: ${throwable.message}"
32+
}
33+
}
34+
35+
/**
36+
* Runs the scanning tasks and blocks the current thread until completion. If the current thread
37+
* is the same as the [handler] thread, then the runnable runs immediately without being enqueued.
38+
* Otherwise, posts the runnable to [handler] and waits for it to complete before returning,
39+
* throwing if timeout occurs or if the work could not be scheduled, and rethrowing any main
40+
* thread exception to the calling thread.
41+
*/
42+
@JvmStatic
43+
public fun HandlerPostingExecutor(
44+
handler: Handler,
45+
timeout: Long,
46+
timeoutUnit: TimeUnit
47+
): ScanExecutor = ScanExecutor { callable ->
48+
if (handler.looper === Looper.myLooper()) {
49+
callable.call()
50+
} else {
51+
var result: Result<String>? = null
52+
val latch = CountDownLatch(1)
53+
val posted = handler.post {
54+
result = try {
55+
Result.success(callable.call())
56+
} catch (throwable: Throwable) {
57+
Result.failure(throwable)
58+
}
59+
latch.countDown()
60+
}
61+
check(posted) {
62+
"Callback not posted, probably because the looper processing the message queue is exiting."
63+
}
64+
check(latch.await(timeout, timeoutUnit)) {
65+
"Could not scan hierarchy from main thread, timed out"
66+
}
67+
result!!.getOrThrow()
68+
}
69+
}
70+
71+
/**
72+
* Runs the scanning tasks synchronously, throwing if the current thread is not the same as the
73+
* thread of the provided [Looper]. Does not catch any exception.
74+
*/
75+
@JvmStatic
76+
public fun LooperEnforcingExecutor(looper: Looper): ScanExecutor = ScanExecutor { callable ->
77+
check(looper === Looper.myLooper()) {
78+
"Should be called from ${looper.thread.name}, not ${Thread.currentThread().name}"
79+
}
80+
callable.call()
81+
}
82+
}

0 commit comments

Comments
 (0)