Radiography 是什么
square/radiography 是一个 Android 开发工具,能够将当前屏幕 View 的层级以字符的形式输出。比如这样:
css
DecorView { 1080×2160px }
├─LinearLayout { id:main, 1080×1962px }
│ ├─EditText { id:username, 580×124px, focused, text-length:0, ime-target }
│ ├─EditText { id:password, 580×124px, text-length:0 }
│ ╰─LinearLayout { 635×154px }
│ ├─Button { id:signin, 205×132px, text-length:7 }
│ ╰─Button { id:forgot_password, 430×132px, text-length:15 }
├─View { id:navigationBarBackground, 1080×132px }
╰─View { id:statusBarBackground, 1080×66px }
有同学会说,Android Studio 不是有 Layout Inspector 么,可以直接在 IDE 里看到所有布局信息。当然,如果你的设备支持你这么做,那显然这个工具对你是没啥用的。但如果某个设备不支持你使用 Layout Inspector ,那么 Radiography 就有用武之地了,它可以将屏幕上布局的属性按你的需求输出,让你方便的查看视图属性,方便你调试 UI。
如何使用
将 Radiography 的依赖添加到工程 app#build.gradle
文件
arduino
dependencies {
implementation 'com.squareup.radiography:radiography:2.5'
}
调用 Radiography#scan()
方法,它将会返回当前屏幕的所有视图层级
kotlin
// Render the view hierarchy for all windows.
val prettyHierarchy = Radiography.scan()
// Include the text content from TextView instances.
val prettyHierarchy = Radiography.scan(viewStateRenderers = DefaultsIncludingPii)
// Append custom attribute rendering
val prettyHierarchy = Radiography.scan(viewStateRenderers = DefaultsNoPii +
androidViewStateRendererFor<LinearLayout> {
append(if (it.orientation == LinearLayout.HORIZONTAL) "horizontal" else "vertical")
})
Radiography#scan()
方法,有多个参数,可以通过参数定义你想要输出的内容
kotlin
public fun scan(
scanScope: ScanScope = AllWindowsScope,
viewStateRenderers: List<ViewStateRenderer> = DefaultsNoPii,
viewFilter: ViewFilter = ViewFilters.NoFilter
): String
比如,像下面这样使用:
kotlin
// Extension function on View, renders starting from that view.
val prettyHierarchy = someView.scan()
// Render only the view hierarchy from the focused window, if any.
val prettyHierarchy = Radiography.scan(scanScope = FocusedWindowScope)
// Filter out views with specific ids.
val prettyHierarchy = Radiography.scan(viewFilter = skipIdsViewFilter(R.id.debug_drawer))
// Combine view filters.
val prettyHierarchy = Radiography.scan(
viewFilter = skipIdsViewFilter(R.id.debug_drawer) and MyCustomViewFilter()
)
结果输出:
css
com.squareup.radiography.sample/com.squareup.radiography.sample.MainActivity:
window-focus:false
DecorView { 1080×2160px }
├─LinearLayout { 1080×2028px }
│ ├─ViewStub { id:action_mode_bar_stub, GONE, 0×0px }
│ ╰─FrameLayout { id:content, 1080×1962px }
│ ╰─LinearLayout { id:main, 1080×1962px }
│ ├─ImageView { id:logo, 1080×352px }
│ ├─EditText { id:username, 580×124px, text-length:0 }
│ ├─EditText { id:password, 580×124px, text-length:0 }
│ ├─CheckBox { id:remember_me, 343×88px, text-length:11 }
│ ├─LinearLayout { 635×154px }
│ │ ├─Button { id:signin, 205×132px, text-length:7 }
│ │ ╰─Button { id:forgot_password, 430×132px, text-length:15 }
│ ├─View { 1080×812px }
│ ╰─Button { id:show_dialog, 601×132px, text-length:23 }
├─View { id:navigationBarBackground, 1080×132px }
╰─View { id:statusBarBackground, 1080×66px }
关于怎么使用就这么多内容,你可能需要在Radiography#scan()
的几个参数上需要花一点时间了解下,它们是干嘛的,总的来说集成是非常方便的。并且,它还支持 Jetpack Compose
,我就不再举例怎么使用了,跟 View 是差不多的,大家可以在Radiography仓库阅读。
怎么实现的?
这个需求说实话不复杂,如果让自己去实现大家各自可能都会有自己的思路,我们来看看 square 工程师的思路。
kotlin
public fun scan(
scanScope: ScanScope = AllWindowsScope,
viewStateRenderers: List<ViewStateRenderer> = DefaultsNoPii,
viewFilter: ViewFilter = ViewFilters.NoFilter
): String = buildString {
val roots = try {
scanScope.findRoots()
} catch (e: Throwable) {
append("Exception when finding scan roots: ${e.message}")
return@buildString
}
//... 省略
}
首先是要找到根布局,调用ScanScope#findRoots()
,默认是 AllWindowScope
,就是整个屏幕范围,我们来跟踪下。
kotlin
public val AllWindowsScope: ScanScope = ScanScope {
Curtains.rootViews
.map(::AndroidView)
}
很简单,其实就一行代码 Curtains.rootViews.map(::AndroidView)
就获取到当前根布局。这里的 Curtains
是 square 的另一个库,就是这个库实现了具体获取当前布局的工作,实现也不复杂,用发射实现。
kotlin
private val mViewsField by lazy(NONE) {
windowManagerClass?.let { windowManagerClass ->
windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true }
}
}
了解 roots
是如何获得之后,我们继续看 scan()
方法的另一半
kotlin
roots.forEach { scanRoot ->
// 获取当前 View 是在哪个线程中操作
// 多数时候 View 都是在主线程操作,但其实 View 可以不在主线程操作,只是要保证所有 View 的操作要在同一个线程
val viewLooper = (scanRoot as? AndroidView)?.view?.handler?.looper
?: Looper.getMainLooper()!!
if (viewLooper.thread == Thread.currentThread()) {
scanFromLooperThread(scanRoot, viewStateRenderers, viewFilter)
} else {
//如果需要切换线程,用 CountDownLatch 做线程同步
val latch = CountDownLatch(1)
Handler(viewLooper).post {
scanFromLooperThread(scanRoot, viewStateRenderers, viewFilter)
latch.countDown()
}
if (!latch.await(5, SECONDS)) {
return "Could not retrieve view hierarchy from main thread after 5 seconds wait"
}
}
}
此部分代码的逻辑简单来说就是根据 View 所在的线程决定 scanFromLooperThread()
方法在哪个线程执行,很简单。scanFromLooperThread()
这个方法就会去渲染能扫描到的视图树,其最终会调用到RenderTreeString#renderRecursively
方法,通过递归渲染出所有视图或 Composable 层级字符树。
kotlin
private fun <N> renderRecursively(
builder: StringBuilder,
node: N,
renderNode: StringBuilder.(N) -> List<N>,
depth: Int,
lastChildMask: BitSet
) {
// Render node into a separate buffer so we can append a prefix to every line.
val nodeDescription = StringBuilder()
val children = nodeDescription.renderNode(node)
nodeDescription.lineSequence().forEachIndexed { index, line ->
builder.appendLinePrefix(depth, continuePreviousLine = index > 0, lastChildMask = lastChildMask)
@Suppress("DEPRECATION")
(builder.appendln(line))
}
val lastChildIndex = children.size - 1
children.forEachIndexed { index, childNode ->
val isLastChild = (index == lastChildIndex)
// Set bit before recursing, will be unset again before returning.
if (isLastChild) {
lastChildMask.set(depth)
}
childNode?.let {
renderRecursively(builder, childNode, renderNode, depth + 1, lastChildMask)
}
}
// Unset the bit we set above before returning.
lastChildMask.clear(depth)
}
整个流程非常清晰,没那么复杂。Radiography
整个库也就十多个类,其中还有不少接口类或扩展类。需要开发者多关注一些的是Radiography#scan()
的三个参数类型:ScanScope
,List<ViewStateRenderer>
,ViewFilter
Radiography#scan()参数
- ScanScope 这个接口定义了需要输出层级的范围,如默认的
AllWindowsScope
范围是当前屏幕所有 Window。另外 SDK 还定义了其他一些 scope 「范围」,如:FocusedWindowScope
、singleViewScope(rootView: View)
- List<ViewStateRenderer> 如果有需要输出额外的一些属性,则可以使用此参数定义,比如我想输出组件的
tag
就可以使用它。SDK 缺省的属性输出其实也是通过ViewStateRenderer
接口实现的,如:AndroidViewRenderer
、ComposeViewRenderer
,大家可以自行阅读源码。 - ViewFilter 过滤器接口,设置不想要输出的条件。默认参数是
NoFilter
,即不需要过滤。开发者可以使用自定义或 SDK 预置的一些过滤器。
最后
关于 Radiography
的介绍就聊到这边,如果你想更详细的了解它,可以自行阅读源码,总的来说这个库还是比较简单、通俗易懂。也欢迎大家多多交流。