Appearance
UI 测试方案设计
v4 版本设计应用内置的 UI 测试方案,用于在开发和测试阶段快速定位 UI 渲染、组件布局等问题。核心约束是:发布版本构建时自动移除所有 UI 测试支持代码和依赖,确保发布 APK 不包含任何调试专用代码、资源或依赖。
1. 构建配置:Release 自动移除
1.1 Source Set 隔离
将所有 UI 测试代码放入独立的 debug 源集,release 构建不包含该源集,从而在编译阶段彻底移除:
code/app/src/
├── main/ ← 正式代码
│ ├── java/ ← 业务代码
│ └── res/ ← 正式资源
├── debug/ ← 仅 debug 构建包含
│ ├── java/ ← UI 测试工具代码
│ └── res/ ← UI 测试专用资源
└── release/ ← 仅 release 构建包含(可选,用于 release 专用配置)Gradle 配置:
groovy
android {
buildTypes {
debug {
// UI 测试依赖仅添加到 debug 构建
}
release {
// 不添加 UI 测试依赖
}
}
}
dependencies {
// UI 测试工具(仅 debug)
debugImplementation "androidx.compose.ui:ui-tooling:{compose_bom_version}"
debugImplementation "androidx.compose.ui:ui-test-manifest:{compose_bom_version}"
}1.2 运行时入口控制
通过 main 源集中的接口定义 UI 测试能力,debug 源集中的实现类提供具体功能:
kotlin
// main 源集:接口定义(空实现,不引入任何依赖)
interface UITestOverlay {
fun enable()
fun disable()
fun toggle(tool: UITestTool)
fun isActive(): Boolean
companion object {
/** 获取 UI 测试覆盖层实例。debug 构建返回真实实现,release 构建返回空实现 */
fun create(): UITestOverlay = UITestOverlayImpl()
}
}
// main 源集:工具枚举
enum class UITestTool {
LayoutBounds, // 布局边界可视化
ComponentInfo, // 组件信息查看
ColorPicker, // 颜色拾取
GridGuides, // 栅格对齐参考线
Recomposition, // 重组追踪
}
// main 源集:空实现(作为 fallback)
private class NoopUITestOverlay : UITestOverlay {
override fun enable() {}
override fun disable() {}
override fun toggle(tool: UITestTool) {}
override fun isActive() = false
}kotlin
// debug 源集:真实实现
class DebugUITestOverlay : UITestOverlay {
private val activeTools = mutableSetOf<UITestTool>()
override fun enable() {
// 激活 UI 测试覆盖层
}
override fun disable() {
activeTools.clear()
}
override fun toggle(tool: UITestTool) {
if (tool in activeTools) activeTools.remove(tool) else activeTools.add(tool)
}
override fun isActive() = activeTools.isNotEmpty()
}
// 通过反射或编译期常量提供真实实现
internal fun UITestOverlay.Companion.createImpl(): UITestOverlay = DebugUITestOverlay()1.3 编译期完全移除验证
通过 ProGuard/R8 规则确保 release 构建中不残留任何 UI 测试类:
proguard
# release 构建移除 UI 测试相关类
-assumenosideeffects class androidx.compose.ui.tooling.** { *; }同时,在 CI 流水线中增加验证步骤,确保 release APK 不包含 UI 测试代码:
bash
# 检查 release APK 中是否包含 UI 测试类
if aapt dump classes release.apk | grep -i "uitest\|debugoverlay"; then
echo "ERROR: Release APK contains UI test classes!"
exit 1
fi2. UI 测试工具设计
2.1 布局边界可视化
在 Compose 组件周围绘制边界线和间距标注,类似 Android View 系统的「显示布局边界」开发者选项,但更精细——支持按组件类型选择显示范围,并标注具体尺寸数值。
kotlin
// debug 源集
@Composable
fun LayoutBoundsOverlay(
content: @Composable () -> Unit,
) {
Box {
content()
if (UITestState.current.isToolActive(UITestTool.LayoutBounds)) {
LayoutBoundsCanvas(
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun LayoutBoundsCanvas(modifier: Modifier = Modifier) {
Canvas(modifier) {
// 从 Compose 的 LayoutInfo 树收集所有组件边界
val rootInfo = (view as? View)?.let { findRootLayoutInfo(it) }
rootInfo?.let { drawBounds(it) }
}
}
private fun DrawScope.drawBounds(info: LayoutInfo) {
// 绘制组件边界矩形
drawRect(
color = Color.Red.copy(alpha = 0.5f),
topLeft = Offset(info.offsetX.toFloat(), info.offsetY.toFloat()),
size = Size(info.width.toFloat(), info.height.toFloat()),
style = Stroke(width = 1.dp.toPx()),
)
// 绘制尺寸标注
drawContext.canvas.nativeCanvas.drawText(
"${info.width.toInt()}x${info.height.toInt()}",
info.offsetX.toFloat(),
info.offsetY.toFloat() - 4.dp.toPx(),
textPaint,
)
// 递归绘制子组件
info.children.forEach { drawBounds(it) }
}Compose LayoutInfo 方案:利用 Compose 的 LayoutInfo 树获取所有组件的测量信息。通过 View.getRootView() 拿到根 View,遍历其 ComposeView 子节点,从 LayoutInfo 获取组件树结构。相比自定义 Modifier 侵入方案,这种方式不需要修改任何业务 Composable。
2.2 组件信息查看
点击任意 UI 组件,显示该组件的详细信息面板:
kotlin
// debug 源集
@Composable
fun ComponentInfoOverlay(
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
) {
var selectedInfo by remember { mutableStateOf<ComponentDebugInfo?>(null) }
Box(modifier = modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.changes.any { it.pressed }) {
val position = event.changes.first().position
selectedInfo = findComponentAt(position)
}
}
}
}) {
// 信息面板
selectedInfo?.let { info ->
ComponentInfoPanel(
info = info,
onDismiss = { selectedInfo = null },
)
}
}
}
data class ComponentDebugInfo(
val name: String, // Composable 函数名
val size: IntSize, // 实际尺寸
val position: Offset, // 在父容器中的位置
val modifiers: List<String>, // Modifier 链描述
val recompositionCount: Int, // 重组次数
val parentInfo: ComponentDebugInfo?, // 父组件信息
)信息面板展示:
┌──────────────────────────────────┐
│ CandidateListPanel │
│ ───────────────────────────── │
│ Size: 1080 x 48 dp │
│ Position: (0, 1200) │
│ Modifiers: │
│ fillMaxWidth() │
│ height(48.dp) │
│ background(CandidateListPanelBg) │
│ padding(horizontal=8.dp) │
│ Recompositions: 12 │
│ ───────────────────────────── │
│ [Copy] [Close] │
└──────────────────────────────────┘2.3 颜色拾取
在键盘界面上拾取任意像素的颜色值:
kotlin
// debug 源集
@Composable
fun ColorPickerOverlay(
modifier: Modifier = Modifier,
onDismiss: () -> Unit,
) {
var pickedColor by remember { mutableStateOf<Color?>(null) }
var cursorPosition by remember { mutableStateOf(Offset.Zero) }
Box(modifier = modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val position = event.changes.firstOrNull()?.position ?: continue
cursorPosition = position
if (event.changes.any { it.pressed }) {
// 从 Bitmap 获取像素颜色
pickedColor = capturePixelColorAt(position)
}
}
}
}) {
// 放大镜 + 十字准心
ColorPickerCursor(position = cursorPosition)
// 颜色信息弹窗
pickedColor?.let { color ->
ColorInfoPopup(
color = color,
onDismiss = { pickedColor = null },
)
}
}
}2.4 栅格对齐参考线
显示栅格线、间距参考和安全区域,验证键盘布局的对齐精度:
kotlin
// debug 源集
@Composable
fun GridGuidesOverlay(
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.fillMaxSize()) {
val gridSpacing = 8.dp.toPx() // 8dp 栅格间距
// 绘制垂直栅格线
var x = 0f
while (x < size.width) {
drawLine(
color = Color.Cyan.copy(alpha = 0.3f),
start = Offset(x, 0f),
end = Offset(x, size.height),
strokeWidth = 0.5.dp.toPx(),
)
x += gridSpacing
}
// 绘制水平栅格线
var y = 0f
while (y < size.height) {
drawLine(
color = Color.Cyan.copy(alpha = 0.3f),
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 0.5.dp.toPx(),
)
y += gridSpacing
}
// 绘制安全区域
val safeArea = getSafeAreaInsets()
drawRect(
color = Color.Red.copy(alpha = 0.1f),
topLeft = Offset(0f, 0f),
size = Size(size.width, safeArea.top.toFloat()),
)
drawRect(
color = Color.Red.copy(alpha = 0.1f),
topLeft = Offset(0f, size.height - safeArea.bottom.toFloat()),
size = Size(size.width, safeArea.bottom.toFloat()),
)
}
}2.5 重组追踪
利用 Compose Compiler 的重组追踪能力,标记频繁重组的组件:
kotlin
// debug 源集
/**
* 重组追踪覆盖层。
*
* 利用 Compose 的 LayoutInfo 树中 ReusableComposeNode 的重组计数,
* 在每个组件上叠加颜色标记:
* - 绿色:0-2 次重组(正常)
* - 黄色:3-5 次重组(需关注)
* - 红色:6+ 次重组(需优化)
*/
@Composable
fun RecompositionOverlay(
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.fillMaxSize()) {
val recompositionData = collectRecompositionData()
recompositionData.forEach { (bounds, count) ->
val color = when {
count <= 2 -> Color.Green.copy(alpha = 0.2f)
count <= 5 -> Color.Yellow.copy(alpha = 0.3f)
else -> Color.Red.copy(alpha = 0.4f)
}
drawRect(
color = color,
topLeft = Offset(bounds.left.toFloat(), bounds.top.toFloat()),
size = Size(bounds.width().toFloat(), bounds.height().toFloat()),
)
}
}
}自定义重组追踪 Modifier:
kotlin
// debug 源集:自定义重组追踪 Modifier
private fun Modifier.recomposeTracker(): Modifier = this.then(
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
// 增加重组计数
RecompositionTracker.record(this@layout)
}
}
)
object RecompositionTracker {
private val counts = mutableMapOf<String, Int>()
fun record(composable: Any) {
val key = composable.javaClass.simpleName
counts[key] = (counts[key] ?: 0) + 1
}
fun getSnapshot(): Map<String, Int> = counts.toMap()
fun reset() = counts.clear()
}3. UITestToolbar
所有 UI 测试工具通过一个可拖动的浮动工具栏切换:
kotlin
// debug 源集
@Composable
fun UITestToolbar(
overlay: UITestOverlay,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
var toolbarOffset by remember { mutableStateOf(Offset(16f, 100f)) }
Box(
modifier = modifier
.offset { IntOffset(toolbarOffset.x.toInt(), toolbarOffset.y.toInt()) }
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
toolbarOffset = toolbarOffset.copy(y = (toolbarOffset.y + delta).coerceIn(0f, 800f))
},
),
) {
if (expanded) {
// 工具面板
Card(
modifier = Modifier.padding(bottom = 48.dp),
) {
Column(modifier = Modifier.padding(8.dp)) {
UITestTool.entries.forEach { tool ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { overlay.toggle(tool) }
.padding(vertical = 8.dp, horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = overlay.isToolActive(tool),
onCheckedChange = { overlay.toggle(tool) },
)
Text(tool.displayName, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
// 触发按钮
FloatingActionButton(
onClick = { expanded = !expanded },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.BugReport,
contentDescription = "UI 测试工具",
)
}
}
}
val UITestTool.displayName: String
get() = when (this) {
UITestTool.LayoutBounds -> "布局边界"
UITestTool.ComponentInfo -> "组件信息"
UITestTool.ColorPicker -> "颜色拾取"
UITestTool.GridGuides -> "栅格参考线"
UITestTool.Recomposition -> "重组追踪"
}3.1 集成入口
在 IMEService 的 ComposeView 层次中,debug 构建额外包裹 UI 测试覆盖层:
kotlin
// main 源集
@Composable
fun InputRoot(state: ImeState, intentHandler: (ImeIntent) -> Unit) {
KeyboardTheme(themeType = state.config.ui.themeType) {
KeyboardPanel(state, intentHandler)
}
}
// debug 源集
@Composable
fun InputRoot(state: ImeState, intentHandler: (ImeIntent) -> Unit) {
KeyboardTheme(themeType = state.config.ui.themeType) {
Box {
KeyboardPanel(state, intentHandler)
// UI 测试覆盖层(仅 debug 构建存在)
val overlay = remember { UITestOverlay.create() }
if (overlay.isActive()) {
UITestOverlays(overlay)
}
UITestToolbar(overlay)
}
}
}通过同一函数签名 InputRoot 在不同源集中的不同实现,debug 构建自动包含 UI 测试覆盖层,release 构建不含任何覆盖层代码。这种方式无需在 main 源集中使用 if (BuildConfig.DEBUG) 判断,确保 release 代码路径完全干净。
4. Compose 编译器报告集成
4.1 编译期重组报告
Compose Compiler 支持在编译时生成重组分析报告,帮助开发者识别不稳定的参数和不必要的重组。在 debug 构建中启用:
groovy
// code/app/build.gradle
android {
buildTypes {
debug {
// 启用 Compose 编译器报告
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler_reports")
metricsDestination = layout.buildDirectory.dir("compose_compiler_metrics")
}
}
}
}4.2 报告分析
编译完成后,通过脚本解析报告中的问题项:
| 报告项 | 含义 | 修复建议 |
|---|---|---|
restartable 但非 skippable | Composable 每次都重组 | 确保参数类型稳定 |
| 不稳定参数 | 参数类型非 Stable/Immutable | 使用 @Immutable 或 @Stable 注解 |
| 高重组频率 | 在短时间内重组次数过多 | 提升状态粒度,使用 derivedStateOf/remember |
5. 截图对比测试
5.1 框架选型
截图对比测试(Screenshot Testing)用于验证 UI 在代码变更后不会出现意外视觉变化。使用 Paparazzi 或 Roborazzi 框架,在 JVM / 设备上渲染 Compose 组件并截图,与基准截图进行像素级对比。
| 框架 | 优势 | 劣势 |
|---|---|---|
| Paparazzi | JVM 上渲染(无需设备),速度快 | 依赖 Android SDK layoutlib,部分 Compose 效果渲染不精确 |
| Roborazzi | 在 Robolectric 环境渲染,支持 Compose | 需要 Robolectric,环境配置复杂 |
推荐 Paparazzi:速度更快,且对 Compose 支持日趋完善。
5.2 测试组织
code/app/src/test/
└── screenshot/
└── org/crazydan/studio/app/ime/kuaizi/ui/
├── keyboard/
│ ├── PinyinKeyboardScreenshotTest.kt
│ ├── LatinKeyboardScreenshotTest.kt
│ └── NumberKeyboardScreenshotTest.kt
├── candidate/
│ └── CandidateListPanelScreenshotTest.kt
├── input/
│ └── InputListPanelScreenshotTest.kt
└── theme/
├── LightThemeScreenshotTest.kt
└── NightThemeScreenshotTest.kt5.3 示例测试
kotlin
class PinyinKeyboardScreenshotTest {
@get:Rule
val paparazzi = Paparazzi()
@Test
fun pinyinKeyboardIdle() {
paparazzi.snapshot {
KeyboardTheme(themeType = ThemeType.Light) {
KeyboardPanel(
state = ImeState(
keyboardType = KeyboardType.Pinyin,
keyboardState = KeyboardState.Idle,
),
intentHandler = {},
)
}
}
}
@Test
fun pinyinKeyboardWithCandidates() {
paparazzi.snapshot {
KeyboardTheme(themeType = ThemeType.Light) {
KeyboardPanel(
state = ImeState(
keyboardType = KeyboardType.Pinyin,
keyboardState = KeyboardState.PinyinInput.Waiting(
inputChars = listOf(charInput('n'), charInput('i')),
candidates = testCandidates,
),
),
intentHandler = {},
)
}
}
}
}5.4 CI 集成
截图对比测试在 CI 流水线中作为独立阶段执行,检测到差异时上传对比图作为 Artifacts:
bash
# 运行截图测试
./gradlew verifyPaparazziDebug
# 如果失败,生成差异报告
./gradlew recordPaparazziDebug # 更新基准截图6. 构建保证机制
6.1 依赖隔离
| 依赖 | 作用 | 构建类型 |
|---|---|---|
androidx.compose.ui:ui-tooling | Layout Inspector、Compose 信息 | debugImplementation |
app.cash.paparazzi:paparazzi | 截图对比测试 | testImplementation |
| UI 测试工具代码 | 布局边界、组件信息、颜色拾取等 | debug 源集 |
6.2 代码隔离检查清单
| 检查项 | 方法 |
|---|---|
| release APK 无 UI 测试类 | CI 脚本检查 APK class 列表 |
| release APK 无 UI 测试资源 | CI 脚本检查 APK 资源列表 |
| release APK 无 ui-tooling 依赖 | 分析 release 构建依赖树 |
| main 源集无 UI 测试引用 | 代码审查 + Lint 规则 |
| release R8 移除所有调试代码 | ProGuard 规则 + APK 反编译验证 |
6.3 Lint 规则
自定义 Lint 规则,防止在 main 源集中意外引用 debug 源集的类:
kotlin
// 自定义 Lint 规则(放在 tools/lint 模块)
class UITestReferenceDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes() = listOf(UCallExpression::class.java)
override fun createUastHandler(context: JavaContext) =
object : UElementHandler() {
override fun visitCallExpression(node: UCallExpression) {
val className = node.classReference?.qualifiedName ?: return
if (className.startsWith("org.crazydan.studio.app.ime.kuaizi.uitest.")) {
// 检查是否在 main 源集中
val sourceSet = context.file.path.substringAfter("/src/").substringBefore("/")
if (sourceSet == "main") {
context.report(
issue = ISSUE_UI_TEST_IN_MAIN,
location = context.getLocation(node),
message = "UI 测试代码不应在 main 源集中引用",
)
}
}
}
}
companion object {
val ISSUE_UI_TEST_IN_MAIN = Issue.create(
id = "UITestReferenceInMain",
briefDescription = "UI 测试引用不应出现在 main 源集",
explanation = "main 源集中的代码会包含在 release 构建中,引用 UI 测试类会导致 release 构建包含调试代码",
category = Category.CORRECTNESS,
severity = Severity.ERROR,
implementation = Implementation(
UITestReferenceDetector::class.java,
Scope.JAVA_FILE_SCOPE,
),
)
}
}7. 与日志系统的协作
UI 测试方案与应用日志系统协同工作:
| 协作点 | 说明 |
|---|---|
| 组件信息 → 日志记录 | 「组件信息查看」工具可将选中组件的调试信息以 INFO 等级写入日志 |
| 重组追踪 → 日志记录 | 重组超过阈值的组件自动记录 WARN 日志,便于后续排查 |
| 布局异常 → 日志警告 | 检测到布局溢出(组件尺寸超出父容器)时自动记录 WARN 日志 |
| 日志等级联动 | UI 测试工具激活时,自动将日志等级降至 DEBUG 以获取更完整信息 |
kotlin
// debug 源集:UI 测试与日志联动
class DebugUITestOverlay(
private val log: ImeLog,
) : UITestOverlay {
override fun enable() {
// UI 测试激活时降级日志等级
if (log.level > LogLevel.DEBUG) {
log.updateLevel(LogLevel.DEBUG)
log.logger("UITest").info { "UI 测试工具已激活,日志等级已降至 DEBUG" }
}
}
override fun toggle(tool: UITestTool) {
if (tool in activeTools) {
activeTools.remove(tool)
} else {
activeTools.add(tool)
log.logger("UITest").debug { "激活工具: ${tool.displayName}" }
}
}
}