Radiography -- 一个实用的 Android UI 层级输出工具

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)就获取到当前根布局。这里的 Curtainssquare 的另一个库,就是这个库实现了具体获取当前布局的工作,实现也不复杂,用发射实现。

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 「范围」,如:FocusedWindowScopesingleViewScope(rootView: View)
  • List<ViewStateRenderer> 如果有需要输出额外的一些属性,则可以使用此参数定义,比如我想输出组件的tag就可以使用它。SDK 缺省的属性输出其实也是通过ViewStateRenderer接口实现的,如:AndroidViewRendererComposeViewRenderer,大家可以自行阅读源码。
  • ViewFilter 过滤器接口,设置不想要输出的条件。默认参数是 NoFilter,即不需要过滤。开发者可以使用自定义或 SDK 预置的一些过滤器。

最后

关于 Radiography 的介绍就聊到这边,如果你想更详细的了解它,可以自行阅读源码,总的来说这个库还是比较简单、通俗易懂。也欢迎大家多多交流。

相关推荐
摸鱼的春哥1 小时前
春哥的Agent通关秘籍07:5分钟实现文件归类助手【实战】
前端·javascript·后端
念念不忘 必有回响1 小时前
viepress:vue组件展示和源码功能
前端·javascript·vue.js
C澒1 小时前
多场景多角色前端架构方案:基于页面协议化与模块标准化的通用能力沉淀
前端·架构·系统架构·前端框架
崔庆才丨静觅1 小时前
稳定好用的 ADSL 拨号代理,就这家了!
前端
江湖有缘1 小时前
Docker部署music-tag-web音乐标签编辑器
前端·docker·编辑器
恋猫de小郭2 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端