Created
May 18, 2025 06:59
-
-
Save logickoder/4b8ea685074c12e25c82cee176ca5e6f to your computer and use it in GitHub Desktop.
Unified View Tree Analyser for Android (Views and Compose)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Identify this composable as a view | |
* | |
* @param tag the view tag, which should uniquely identify this element within the full Composition being rendered. | |
*/ | |
@Stable | |
fun Modifier.viewTag(tag: String): Modifier = semantics( | |
properties = { | |
viewTagProperty = tag | |
} | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.content.Context | |
import android.util.Log | |
import android.view.View | |
import android.view.ViewGroup | |
import androidx.compose.ui.semantics.SemanticsNode | |
import androidx.compose.ui.semantics.SemanticsOwner | |
import androidx.compose.ui.semantics.SemanticsProperties | |
import androidx.compose.ui.semantics.SemanticsPropertyKey | |
import androidx.compose.ui.semantics.SemanticsPropertyReceiver | |
import androidx.compose.ui.semantics.getOrNull | |
import com.google.gson.GsonBuilder | |
import com.google.gson.JsonArray | |
import com.google.gson.JsonObject | |
import java.lang.reflect.Field | |
import kotlin.math.roundToInt | |
private val ViewTagKey = SemanticsPropertyKey<String>("ViewTagKey") | |
internal var SemanticsPropertyReceiver.viewTagProperty by ViewTagKey | |
object ViewTreeAnalyser { | |
private const val ANDROID_COMPOSE_VIEW_CLASS_NAME = | |
"androidx.compose.ui.platform.AndroidComposeView" | |
private val semanticsOwnerField: Field? by lazy { | |
try { | |
Class.forName(ANDROID_COMPOSE_VIEW_CLASS_NAME) | |
.getDeclaredField("semanticsOwner") | |
.apply { isAccessible = true } | |
} catch (e: Exception) { | |
Log.e("ViewTreeAnalyser", "Reflection failed: Could not find semanticsOwner field", e) | |
null | |
} | |
} | |
/** | |
* Analyses the view tree starting from the root View and generates a JSON representation. | |
* Includes both traditional Android Views and Compose Views embedded within. | |
* Reports coordinates relative to the application window. | |
* | |
* @param root The root View of the hierarchy to analyse. | |
* @param screenName A name for the screen being analysed. | |
* @return A JsonObject representing the view tree. | |
*/ | |
fun analyseViewRoot(root: View, screenName: String): JsonObject { | |
val resultJson = JsonObject().apply { | |
addProperty("name", screenName) | |
val children = JsonArray() | |
analyseViewElement( | |
root, | |
onElementAnalysed = { children.add(it) } | |
) | |
add("children", children) | |
} | |
val gson = GsonBuilder().setPrettyPrinting().create() | |
val formattedJson = gson.toJson(resultJson) | |
Log.e( | |
"ViewTreeAnalyser", | |
"View tree for screen: $screenName\n$formattedJson" | |
) | |
return resultJson | |
} | |
/** | |
* Recursively analyses a single View element and its children. | |
* Reports coordinates relative to the application window. | |
* | |
* @param view The current View to analyse. | |
* @param onElementAnalysed Callback to receive the JsonObject for the analysed element. | |
*/ | |
private fun analyseViewElement(view: View, onElementAnalysed: (JsonObject) -> Unit) { | |
// Calculate window-relative position for traditional Views | |
val locationInWindow = IntArray(2) | |
view.getLocationInWindow(locationInWindow) | |
val xInWindow = locationInWindow[0] | |
val yInWindow = locationInWindow[1] | |
onElementAnalysed( | |
JsonObject().apply { | |
addProperty( | |
"id", try { | |
when (view.id) { | |
View.NO_ID -> "no-id-${view.hashCode()}" | |
else -> view.resources.getResourceEntryName(view.id) | |
} | |
} catch (_: Exception) { | |
// Handle cases where resource name is not available | |
"unknown-id-${view.hashCode()}" | |
} | |
) | |
addProperty("type", view.javaClass.simpleName) | |
addProperty("clickable", view.isClickable) | |
addProperty("focusable", view.isFocusable) | |
addProperty( | |
"visibility", when (view.visibility) { | |
View.VISIBLE -> "visible" | |
View.INVISIBLE -> "invisible" | |
View.GONE -> "gone" | |
else -> "unknown" | |
} | |
) | |
// Report frame in pixels relative to the application window | |
add( | |
"frame", | |
JsonObject().apply { | |
addProperty("x", xInWindow) | |
addProperty("y", yInWindow) | |
addProperty("width", view.width) | |
addProperty("height", view.height) | |
} | |
) | |
} | |
) | |
// Recursively analyse children for ViewGroups | |
if (view is ViewGroup) { | |
for (i in 0 until view.childCount) { | |
analyseViewElement(view.getChildAt(i), onElementAnalysed) | |
} | |
} | |
// Handle embedded AndroidComposeView | |
if (view::class.java.name == ANDROID_COMPOSE_VIEW_CLASS_NAME) { | |
analyseComposeView(view, onElementAnalysed) | |
} | |
} | |
/** | |
* Analyses the Compose tree within a AndroidComposeView using reflection. | |
* WARNING: This uses reflection on internal Compose APIs and is highly fragile. | |
* Reports coordinates relative to the application window. | |
* | |
* @param view The AndroidComposeView to analyse. | |
* @param onElementAnalysed Callback to receive the JsonObject for the analysed element. | |
*/ | |
private fun analyseComposeView( | |
view: View, | |
onElementAnalysed: (JsonObject) -> Unit | |
) { | |
@Suppress("TooGenericExceptionCaught") | |
try { | |
val semanticsOwner = semanticsOwnerField?.get(view) as? SemanticsOwner | |
if (semanticsOwner != null) { | |
// Analyse the root semantics node of the Compose tree | |
analyseSemanticsNode( | |
semanticsOwner.rootSemanticsNode, | |
view.context, | |
onElementAnalysed | |
) | |
} else { | |
Log.w( | |
"ViewTreeAnalyser", | |
"Could not get SemanticsOwner from ComposeView via reflection." | |
) | |
} | |
} catch (ex: Exception) { | |
// Catching and swallowing exceptions here with the Compose view handling in case | |
// something changes in the future that breaks the expected structure being accessed | |
// through reflection here. If anything goes wrong within this block, prefer to continue | |
// processing the remainder of the view tree as best we can. | |
Log.e( | |
"ViewTreeAnalyser", | |
"Error processing Compose layout via reflection: ${ex.message}" | |
) | |
} | |
} | |
/** | |
* Recursively analyses a SemanticsNode and its children. | |
* Reports coordinates relative to the application window. | |
* | |
* @param semanticsNode The current SemanticsNode to analyse. | |
* @param context The Android Context (needed for density if converting units). | |
* @param onElementAnalysed Callback to receive the JsonObject for the analysed element. | |
*/ | |
private fun analyseSemanticsNode( | |
semanticsNode: SemanticsNode, | |
context: Context, | |
onElementAnalysed: (JsonObject) -> Unit | |
) { | |
// Use positionInWindow for window-relative coordinates | |
val xInWindow = semanticsNode.positionInWindow.x.roundToInt() | |
val yInWindow = semanticsNode.positionInWindow.y.roundToInt() | |
val widthPx = semanticsNode.size.width | |
val heightPx = semanticsNode.size.height | |
onElementAnalysed( | |
JsonObject().apply { | |
// Use the viewTagProperty if available, fallback to "no-id" or generated | |
addProperty( | |
"id", | |
semanticsNode.config.getOrNull(ViewTagKey) | |
?: "no-id-${semanticsNode.hashCode()}" | |
) | |
// Get Role if available, otherwise null | |
addProperty( | |
"type", | |
semanticsNode.config.getOrNull(SemanticsProperties.Role)?.toString() | |
) | |
// Add other relevant semantics properties if needed, e.g., Text, ContentDescription | |
addProperty( | |
"text", | |
semanticsNode.config.getOrNull(SemanticsProperties.Text)?.joinToString("") | |
) | |
addProperty( | |
"contentDescription", | |
semanticsNode.config.getOrNull(SemanticsProperties.ContentDescription) | |
?.joinToString("") | |
) | |
// Report frame in pixels relative to the application window | |
add( | |
"frame", | |
JsonObject().apply { | |
addProperty("x", xInWindow) | |
addProperty("y", yInWindow) | |
addProperty("width", widthPx) | |
addProperty("height", heightPx) | |
} | |
) | |
} | |
) | |
// Recursively analyse children SemanticsNodes | |
semanticsNode.children.forEach { child -> | |
analyseSemanticsNode(child, context, onElementAnalysed) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment