当手机版 Compose 组件在手表上崩掉:Wear OS 声明式 UI 的排坑之路

去年把手机端一套 Compose 布局直接迁移到 Wear OS,LazyColumn 在圆形表盘上滚动时内容被裁切得面目全非。第一反应是「加个 padding 不就行了」——远没那么简单。手表屏幕的物理约束决定了它需要一套完全不同的组件模型,尤其是列表滚动和后台服务这两块。

硬件约束如何重塑组件

手表屏幕在 1.2-1.6 英寸之间,圆形居多,ppi 高但可视面积极小。两个约束直接决定了组件设计方向:

第一个是裁剪区域。 圆形屏幕按矩形 FrameLayout 布局,四角必然被切掉。手机端靠 paddingclipToPadding=false 能处理——在这里失效,表盘四个角的空白区域不是设计取舍,是物理事实。

Wear OS 的 Material 主题内置了水平方向的 percentage padding:

// Wear OS 自动处理的水平内边距,圆形表盘约为 5.2%
compositionLocalOf { 0.052f }

不需要手动处理,但自定义组件得从 WearSystem 里拿这个值。所有标准组件(Chip、Card、Button)都默认留出了弧形安全区。

第二个是滚动方向。 表冠是垂直旋转的,竖着转比横着划更自然,因此 Wear OS 的主列表都是垂直滚动HorizontalPager 只用于表盘切换这类低频场景。回到上面的翻车案例——LazyColumn 的问题在于它的 item 没有处理圆形视口边缘的缩放效果,首尾 item 会直接切进表框。

ScalingLazyColumn:不只是「手表版 LazyColumn」

ScalingLazyColumn 的核心机制是视口缩放:离屏幕中心越远的 item,透明度越高、scale 越小。它解决了两个工程问题:

  1. 用户始终能看到当前聚焦的 item 完整内容
  2. 滚出视区的 item 通过缩小和变淡来提示位置,而不是被粗暴裁切
ScalingLazyColumn(
    modifier = Modifier.fillMaxSize(),
    autoCentering = AutoCenteringParams(itemIndex = 0, itemOffset = 0),
    scalingParams = ScalingLazyColumnDefaults.scalingParams(
        edgeScale = 0.6f,      // 边缘 item 缩放到 60%
        edgeAlpha = 0.3f       // 边缘 item 透明度 30%
    )
) {
    items(messageList) { message ->
        ChatCard(message)
    }
}

edgeScaleedgeAlpha 控制衰减曲线。建议把 edgeScale 设为 0.6-0.7,太低会让用户以为列表已经到头了,太高则裁切感依然明显。

踩过的坑: autoCentering 默认以列表第一个 item 为锚点,如果顶部有 header 组件,需要把 itemIndex 指向真正的内容首项。否则旋转表冠时列表会弹回 header 位置,体验像 bug。

列表状态与可见性检测

ScalingLazyListState 提供了 layoutInfo.visibleItemsInfo,能拿到当前可见 item 的信息:

val listState = rememberScalingLazyListState()
val centerItem = listState.layoutInfo.visibleItemsInfo
    .minByOrNull { abs(it.unadjustedOffset) }

LaunchedEffect(centerItem) {
    centerItem?.let {
        viewModel.onItemFocused(it.index)
    }
}

「旋转表冠选中 → 自动播放语音」这类交互就是建立在这个基础上的——不需要监听滚动手势,状态变化直接映射到业务逻辑。

列表锚点与 RotaryScroll 协同

RotaryScrollHandler 是 Wear OS 的输入抽象层,负责把表冠旋转事件转成列表滚动。常规场景下不需要手动处理,但自定义组件绕不开它:

@Composable
fun CustomPicker(
    state: ScalingLazyListState,
    onFling: () -> Unit
) {
    ScalingLazyColumn(state = state) { /* items */ }
    
    // 自定义表冠行为:快速旋转触发侧滑
    RotaryScrollHandler { event ->
        if (event.verticalScrollPixels > 100) {
            onFling()
        }
        true
    }
}

普通场景直接用默认行为。自定义 RotaryScrollHandler 要返回 true,否则事件会继续传播给系统手势,表冠转不动。

Tile 服务:手表上的「卡片式微应用」

如果 ScalingLazyColumn 是 Wear OS 的 RecyclerView,那 Tile 就是手表端的 Widget。它运行在受限沙箱里,有几个硬性约束:

  • 不能访问网络。Tile 是纯本地渲染的服务
  • 没有生命周期感知。不绑定 Activity,onTileResourcesRequest 触发时甚至没有 Context
  • 只能跑 10 秒。超过时限系统直接杀进程

这些约束意味着 Tile 的所有数据必须在请求之前准备好。典型架构是:手机端同步数据到手表的 DataLayer,Tile 从本地读取。

class WeatherTileService : TileService() {

    override fun onResourcesRequest(requestParams: ResourcesRequest) =
        serviceScope.future {
            // 从本地 DataStore 拿数据,不能在这里做网络请求
            val weather = repository.getLatest()
            Tile.Builder()
                .setResourcesVersion(weather.version)
                .setTile(buildTile(weather))
                .build()
        }

    override fun onTileAddEvent(requestParams: AddEventRequest) {
        // Tile 被添加到表盘时触发,可在此做初始化
    }
}

声明式 Tile 布局:代码即模板

Tile 的布局不是 XML 也不是 Compose,而是通过 builder 模式描述:

private fun buildTile(weather: WeatherData) = Tile.Builder()
    .setTimeline(
        Timeline.fromLayoutElement(
            LayoutElementBuilders.Row.Builder()
                .setWidth(DimensionBuilders.expand())
                .addContent(
                    LayoutElementBuilders.Column.Builder()
                        .addContent(
                            LayoutElementBuilders.Text.Builder()
                                .setText("${weather.temp}°")
                                .setTypography(Typography.TYPOGRAPHY_DISPLAY1)
                                .build()
                        )
                        .addContent(
                            LayoutElementBuilders.Text.Builder()
                                .setText(weather.condition)
                                .setTypography(Typography.TYPOGRAPHY_CAPTION1)
                                .build()
                        )
                        .build()
                )
                .build()
        )
    ).build()

写法冗长,但好处是静态度极高——没有重组、没有 diff,渲染路径极短。Tile 是预编译的布局树,切到表盘时几乎 0 延迟。

用 Compose 思路组织 Tile 代码

Builder 嵌套太深时,可以把布局单元抽成工厂方法:

object TileLayouts {
    fun primaryText(value: String) = Text.Builder()
        .setText(value)
        .setTypography(Typography.TYPOGRAPHY_TITLE1)

    fun row(vararg elements: LayoutElement) = Row.Builder()
        .apply { elements.forEach { addContent(it) } }
        .build()
}

配合 Kotlin 扩展函数能进一步精简。我在项目里最终形成了一个类似 @Composable 风格的 DSL,每个 Tile 的 build() 方法不超过 30 行。

Tile 与 DataLayer 的数据同步管道

Tile 不能访问网络,数据必须走 Wearable DataLayer。标准流程:

手机 App → DataClient.putDataItem() → Play Services → 手表 DataClient → 本地存储 → Tile 读取

onResourcesRequestfuture {} 做的 IO 操作包括 DataLayer 读取。如果数据同步延迟导致读取耗时超过 8 秒,Tile 直接白屏。解法是在 Tile 之外的 Service 里预拉取数据到本地 Room/DataStore

// 独立 Service,监听 DataLayer 变化并持久化
class DataSyncService : WearableListenerService() {
    override fun onDataChanged(dataEvents: DataEventBuffer) {
        // 将变动的数据全部写入本地
        // Tile 启动时直接从本地读取
    }
}

这样 onResourcesRequest 的耗时可以控制在毫秒级。

GradientEdge:小屏上的视觉引导

手表屏幕太小,用户经常看不到列表边缘有没有更多内容。ScalingLazyColumn 默认支持 GradientEdge——列表顶部和底部有渐隐指示:

ScalingLazyColumn(
    modifier = Modifier.gradientEdge(
        ScalingLazyColumnDefaults.gradientEdge()
    )
)

这个渐变只出现在有额外内容可滚动的方向上,滑到底部时底部渐变自动消失。本质上是在 View 层级叠加了一个 Canvas 绘制层,对性能无影响。

实践建议

在 Wear OS 上做列表类 UI,几条用坑换来的经验:

能交给 ScalingLazyColumn 就别自己滚。 它处理了圆形裁剪、表冠输入和缩放反馈,自己实现的工作量远大于适配成本。

把 Tile 当成静态快照,而不是动态 Widget。 数据新鲜度控制在分钟级就够了。数据拉取逻辑完全放在 Tile 之外,onResourcesRequest 是渲染入口,不是数据请求入口。

务必用圆形模拟器测试。 方形屏和圆形屏的裁剪区域差异会让布局在真机上直接报废。模拟器里圆屏切角的视觉效果会暴露所有 padding 问题——在方形屏上看着完美的布局,切到圆屏可能第一行就没了。