likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 3

Radiography 是什么

square/radiography 是一个 Android 开发工具,能够将当前屏幕 View 的层级以字符的形式输出。比如这样:

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 文件

dependencies {
  implementation 'com.squareup.radiography:radiography:2.5'
}

调用 Radiography#scan()方法,它将会返回当前屏幕的所有视图层级

// 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() 方法,有多个参数,可以通过参数定义你想要输出的内容

public fun scan(  
    scanScope: ScanScope = AllWindowsScope,  
    viewStateRenderers: List<ViewStateRenderer> = DefaultsNoPii,  
    viewFilter: ViewFilter = ViewFilters.NoFilter  
): String

比如,像下面这样使用:

// 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()
)

结果输出:

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

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 工程师的思路。

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,就是整个屏幕范围,我们来跟踪下。

public val AllWindowsScope: ScanScope = ScanScope {  
    Curtains.rootViews  
        .map(::AndroidView)  
}

很简单,其实就一行代码 Curtains.rootViews.map(::AndroidView)就获取到当前根布局。这里的 Curtainssquare 的另一个库,就是这个库实现了具体获取当前布局的工作,实现也不复杂,用发射实现。

private val mViewsField by lazy(NONE) {  
    windowManagerClass?.let { windowManagerClass ->  
        windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true }  
    }  
}

了解 roots 是如何获得之后,我们继续看 scan()方法的另一半

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 层级字符树。

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