在 Wear OS 中创建您的第一个卡片

1. 简介

手表动画演示:用户将表盘滑动到第一个预测功能块上,再滑动到一个计时器功能块,然后再返回

您可以通过 Wear OS 卡片轻松访问用户完成各种任务所需的信息和操作。只需在表盘上轻轻滑动一下,用户即可查看最新的天气预报或启动定时器。

功能块作为系统界面的一部分运行,而不是在其专属的应用容器中运行。我们使用 Service 来描述功能块的布局和内容。系统界面随后会根据需要渲染功能块。

实践内容

35a459b77a2c9d52.png

您将为一款即时通讯应用构建功能块,其中将显示近期对话。通过此 surface,用户可以直接执行以下三项常见任务中的一项:

  • 打开对话
  • 搜索对话
  • 撰写新消息

学习内容

在此 Codelab 中,您将学习如何编写自己的 Wear OS 卡片,包括如何执行以下操作:

  • 创建 TileService
  • 在设备上测试功能块
  • 在 Android Studio 中预览功能块的界面
  • 为功能块开发界面
  • 添加图片
  • 处理互动

前提条件

  • Kotlin 有基本的了解

2. 准备工作

在此步骤中,您将设置环境并下载起始项目。

所需条件

  • Android Studio Dolphin (2021.3.1) 或更高版本
  • Wear OS 设备或模拟器

如果您不熟悉 Wear OS 的用法,在开始之前先阅读此快速指南将对您有所帮助。文中介绍了如何设置 Wear OS 模拟器,以及如何在系统中导航。

下载代码

如果您已安装 git,则只需运行以下命令即可克隆此代码库中的代码。如需检查是否已安装 git,请在终端或命令行中输入 git –version,并验证其是否正确执行。

git clone https://github.com/android/codelab-wear-tiles.git
cd wear-tiles

如果您未安装 git,可以点击下方按钮下载此 Codelab 的全部代码:

在 Android Studio 中打开项目

在“Welcome to Android Studio”窗口中,选择 c01826594f360d94.png Open an Existing Project 或依次选择 File > Open,然后选择文件夹 [Download Location]

3.创建基本功能块

卡片的入口点是卡片服务。在此步骤中,您将注册一项卡片服务,并定义该卡片的布局。

HelloWorldTileService

实现 TileService 的类需要指定两个函数:

  • onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture<Resources>
  • onTileRequest(requestParams: TileRequest): ListenableFuture<Tile>

第一个函数会将字符串 ID 映射到图片资源。我们将在此函数中提供要在功能块中使用的图片资源。

第二个函数会返回功能块的说明(包括其布局)。我们将在此函数中定义功能块的布局以及数据与功能块的绑定方式。

start 模块打开 HelloWorldTileService.kt。您将进行的所有更改均位于此模块中。我们还提供了一个 finished 模块,供您查看此 Codelab 的成果。

HelloWorldTileService 会扩展 CoroutinesTileService,后者是一个来自 Horologist Tiles 库的对 Kotlin 协程友好的封装容器。Horologist 是 Google 的一组库,旨在为 Wear OS 开发者提供开发者通常需要但 Jetpack 尚未提供的功能。

CoroutinesTileService 提供了两个挂起函数,这两个挂起函数是 TileService 中的函数的协程版本:

  • suspend resourcesRequest(requestParams: ResourcesRequest): Resources
  • suspend tileRequest(requestParams: TileRequest): Tile

如需详细了解协程,请参阅 Android 上的 Kotlin 协程文档。

HelloWorldTileService 尚未完成。我们需要在清单中注册服务,还需要为 tileLayout 提供实现。

注册卡片服务

必须在清单中注册卡片服务,以便系统了解它。一旦注册完毕,它便会出现在可供用户添加的卡片列表中。

<application> 元素内添加 <service>

start/src/main/AndroidManifest.xml

<service
    android:name="com.example.wear.tiles.hello.HelloWorldTileService"
    android:icon="@drawable/ic_waving_hand_24"
    android:label="@string/hello_tile_label"
    android:description="@string/hello_tile_description"
    android:exported="true"
    android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">

    <intent-filter>
        <action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
    </intent-filter>

    <!-- The tile preview shown when configuring tiles on your phone -->
    <meta-data
        android:name="androidx.wear.tiles.PREVIEW"
        android:resource="@drawable/tile_hello" />
</service>

当首次加载功能块时,或者加载功能块出错时,系统将使用图标和标签(作为占位符)。末尾的元数据定义了当用户添加功能块时在轮播界面中显示的预览图片。

定义功能块布局

HelloWorldTileService 有一个名为 tileLayout 的函数,其正文为 TODO()。现在,我们将其替换为一个实现,并在其中定义功能块的布局,然后绑定数据:

start/src/main/java/com/example/wear/tiles/hello/HelloWorldTileService.kt

private fun tileLayout(): LayoutElement {
    val text = getString(R.string.hello_tile_body)
    return LayoutElementBuilders.Box.Builder()
        .setVerticalAlignment(LayoutElementBuilders.VERTICAL_ALIGN_CENTER)
        .setWidth(DimensionBuilders.expand())
        .setHeight(DimensionBuilders.expand())
        .addContent(
            LayoutElementBuilders.Text.Builder()
                .setText(text)
                .build()
        )
        .build()
}

我们将创建一个 Text 元素并将其设置在 Box 内,以便进行一些基本的对齐。

这是您创建的第一个 Wear OS 卡片!让我们安装此卡片,看看其外观如何。

4. 在设备上测试功能块

在运行配置下拉菜单中选择启动模块后,您可以在设备或模拟器中安装应用(start 模块),并像用户一样手动安装功能块。

我们改为使用 Android Studio Dolphin 推出的 Direct Surface Launch 功能来创建一个新的运行配置,以便直接从 Android Studio 启动功能块。从顶部面板的下拉菜单中选择“Edit Configurations...”。

从 Android Studio 的顶部面板中运行配置下拉菜单。“Edit configurations”已高亮显示。

点击“Add new configuration”按钮,然后选择“Wear OS Tile”。添加描述性名称,然后选择 Tiles_Code_Lab.start 模块和 HelloWorldTileService 功能块。

按“OK”完成。

Edit Configuration 菜单,其中正在配置一个名称为 HelloTile 的 Wear OS 卡片。

利用 Direct Surface Launch,我们可以在 Wear OS 模拟器或实体设备上快速测试卡片。运行“HelloTile”试试看。它应如以下的屏幕截图所示。

以黑底白字显示“Time to create a tile!”的圆形手表

5. 构建消息功能块

显示 5 个圆形按钮(2x3 金字塔形)的圆形手表。第 1 个和第 3 个按钮以紫色文字显示首字母缩写,第 2 个和第 4 个按钮显示个人资料照片,最后一个按钮显示搜索图标。按钮下方是紫色紧凑条状标签,其中显示黑色文本的“New”。

我们要构建的消息功能块更像是一个真实的功能块。与 HelloWorld 示例不同,此示例将从本地代码库加载数据、从网络获取要显示的图片,并直接从功能块处理互动以打开应用。

MessagingTileService

MessagingTileService 扩展了我们之前看到的 CoroutinesTileService 类。

此示例与上一个示例的主要区别在于,我们现在从代码库观察数据,并从网络中提取图片数据。

对于任何可能长时间运行的工作(例如网络调用),更适合使用 WorkManager 之类的工具,因为卡片服务函数的超时时间相对较短。在此 Codelab 中,我们不会介绍 WorkManager。若要亲自实践,请查看此 Codelab

MessagingTileRenderer

MessagingTileRenderer 扩展了 TileRenderer 类(这是 Horologist Tiles 的另一种抽象化形式)。它是完全同步的 - 状态会传递到渲染函数,让用户能够更轻松地在测试和 Android Studio 预览中使用。

在下一步中,我们将介绍如何为卡片添加 Android Studio 预览。

6. 添加预览函数

我们可以使用 Horologist Tiles 中的 TileLayoutPreview(或类似方法)来预览 Android Studio 中的功能块界面。这样可以缩短开发界面时的反馈环,从而加快迭代速度。

我们将使用 Jetpack Compose 中的工具查看此预览,因此您将在以下预览函数中看到 @Composable 注解。您可以详细了解可组合项预览,但这并非完成此 Codelab 的必要步骤。

在文件末尾为 MessagingTileRenderer 添加可组合项预览。

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

@WearDevicePreview
@Composable
fun MessagingTileRendererPreview() {
    TileLayoutPreview(
        state = MessagingTileState(MessagingRepo.knownContacts),
        resourceState = emptyMap(),
        renderer = MessagingTileRenderer(LocalContext.current)
    )
}

请注意,可组合函数使用 TileLayoutPreview;我们无法直接预览卡片布局。

使用“拆分”编辑器模式查看功能块预览:

Android Studio 的分屏视图,左侧是预览代码,右侧是功能块图片。

我们在 MessagingTileState 中传递人工数据,并且还没有任何资源状态,因此可以传递空映射。

在下一步中,我们将使用 Tiles Material 更新布局。

7. 添加 Tiles Material

Tiles Material 提供预构建的 Material 组件和布局,让您可以创建支持适用于 Wear OS 的最新 Material Design 的卡片。

将 Tiles Material 依赖项添加到 build.gradle 文件中:

start/build.gradle

implementation "androidx.wear.tiles:tiles-material:$tilesVersion"

根据设计的复杂程度,将布局代码与渲染器共置极具实用性,因为您可以使用同一个文件中的顶级函数来封装界面的逻辑单元。

将按钮的代码添加到渲染程序文件的底部,并添加预览:

start/src/main/java/MessagingTileRenderer.kt

private fun searchLayout(
    context: Context,
    clickable: ModifiersBuilders.Clickable,
) = Button.Builder(context, clickable)
    .setContentDescription(context.getString(R.string.tile_messaging_search))
    .setIconContent(MessagingTileRenderer.ID_IC_SEARCH)
    .setButtonColors(ButtonColors.secondaryButtonColors(MessagingTileTheme.colors))
    .build()

@IconSizePreview
@Composable
private fun SearchButtonPreview() {
    LayoutElementPreview(
        searchLayout(
            context = LocalContext.current,
            clickable = emptyClickable
        )
    ) {
        addIdToImageMapping(
            MessagingTileRenderer.ID_IC_SEARCH,
            drawableResToImageResource(R.drawable.ic_search_24)
        )
    }
}

LayoutElementPreview 类似于 TileLayoutPreview,但用于单个组件,例如按钮、条状标签或标签。借助末尾的尾随 lambda,我们可以指定资源 ID 映射(到图片资源),因此在此处,我们将 ID_IC_SEARCH 映射到搜索图片资源。

使用“拆分”编辑器模式,我们可以看到搜索按钮的预览:

一组垂直堆叠的预览,顶部是功能块,下方是搜索图标按钮。

我们也可以执行类似操作来构建联系人布局:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

private fun contactLayout(
    context: Context,
    contact: Contact,
    clickable: ModifiersBuilders.Clickable,
) = Button.Builder(context, clickable)
    .setContentDescription(contact.name)
    .apply {
        if (contact.avatarUrl != null) {
            setImageContent(contact.imageResourceId())
        } else {
            setTextContent(contact.initials)
            setButtonColors(ButtonColors.secondaryButtonColors(MessagingTileTheme.colors))
        }
    }
    .build()

Tiles Material 并不是仅包含组件。我们可以使用 Tiles Material 中的布局来快速实现所需的外观,而不是使用一系列嵌套列和行。

在此处,我们可以使用 PrimaryLayoutMultiButtonLayout 来排列 4 个联系人和搜索按钮。使用以下布局更新 MessagingTileRenderer 中的 messagingTileLayout() 函数:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

private fun messagingTileLayout(
    context: Context,
    deviceParameters: DeviceParametersBuilders.DeviceParameters,
    state: MessagingTileState
) = PrimaryLayout.Builder(deviceParameters)
    .setContent(
        MultiButtonLayout.Builder()
            .apply {
                // In a PrimaryLayout with a compact chip at the bottom, we can fit 5 buttons.
                // We're only taking the first 4 contacts so that we can fit a Search button too.
                state.contacts.take(4).forEach { contact ->
                    addButtonContent(
                        contactLayout(
                            context = context,
                            contact = contact,
                            clickable = emptyClickable
                        )
                    )
                }
            }
            .addButtonContent(searchLayout(context, emptyClickable))
            .build()
    )
    .build()

包含 2x3 金字塔中的 5 个按钮的功能块预览。第 2 个和第 3 个按钮显示为蓝色实心圆圈,指示缺少图片。

MultiButtonLayout 最多支持 7 个按钮,这些按钮将为您留出适当的间距。接下来将“New”条状标签也添加到 PrimaryLayout 中(messagingTileLayout() 函数中的 PrimaryLayout 构建器上):

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

.setPrimaryChipContent(
    CompactChip.Builder(
        /* context = */ context,
        /* text = */ context.getString(R.string.tile_messaging_create_new),
        /* clickable = */ emptyClickable,
        /* deviceParameters = */ deviceParameters
    )
        .setChipColors(ChipColors.primaryChipColors(MessagingTileTheme.colors))
        .build()
)

功能块预览:包含 5 个按钮,下方还有一个文本为“New”的紧凑条状标签

在下一步中,我们将修复缺失的图片。

8. 添加图片

在功能块上显示本地图片是一项简单任务:使用 Horologist Tiles 中的便捷函数加载可绘制对象并将其转换为图片资源,以便提供从布局中使用的字符串 ID 到图片的映射。SearchButtonPreview 中提供了一个示例:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

addIdToImageMapping(
    ID_IC_SEARCH,
    drawableResToImageResource(R.drawable.ic_search_24)
)

对于消息功能块,我们还需要从网络(而不仅仅从本地资源)加载图片,为此,我们使用 Coil(一种基于 Kotlin 协程的图片加载器)。

代码已编写如下:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileService.kt

override suspend fun resourcesRequest(requestParams: ResourcesRequest): Resources {
    val avatars = imageLoader.fetchAvatarsFromNetwork(
        context = this@MessagingTileService,
        requestParams = requestParams,
        tileState = latestTileState()
    )
    return renderer.produceRequestedResources(avatars, requestParams)
}

由于卡片渲染程序是完全同步的,因此卡片服务将从网络中提取位图。与之前一样,根据图片大小,一种更加合适的方法是使用 WorkManager 提前获取图片,但在此 Codelab 中,我们将直接获取图片。

我们将 avatars 映射(ContactBitmap)作为资源的“状态”传递给渲染程序。现在,渲染程序可以将这些位图转换为卡片的图片资源。

此代码也已编写完成:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

override fun ResourceBuilders.Resources.Builder.produceRequestedResources(
    resourceState: Map<Contact, Bitmap>,
    deviceParameters: DeviceParametersBuilders.DeviceParameters,
    resourceIds: MutableList<String>
) {
    addIdToImageMapping(
        ID_IC_SEARCH,
        drawableResToImageResource(R.drawable.ic_search_24)
    )

    resourceState.forEach { (contact, bitmap) ->
        addIdToImageMapping(
            /* id = */ contact.imageResourceId(),
            /* image = */ bitmap.toImageResource()
        )
    }
}

因此,如果服务要提取位图,而渲染程序要将这些位图转换为图片资源,那么功能块为何未显示图片?

没错!如果您在具有互联网连接的设备上运行功能块,那么您应当看到图片确实进行了加载。问题仅存在于预览环节,因为我们仍在为 resourceState 传递 emptyMap()

对于实际功能块,我们将从网络中提取位图并将其映射到不同的联系人,但对于预览和测试,我们完全不需要连接到网络。

更新 MessagingTileRendererPreview(),以便我们为两个联系人提供所需的位图:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

@WearDevicePreview
@Composable
fun MessagingTileRendererPreview() {
    val state = MessagingTileState(MessagingRepo.knownContacts)
    val context = LocalContext.current
    TileLayoutPreview(
        state = state,
        resourceState = mapOf(
            state.contacts[1] to (context.getDrawable(R.drawable.ali) as BitmapDrawable).bitmap,
            state.contacts[2] to (context.getDrawable(R.drawable.taylor) as BitmapDrawable).bitmap,
        ),
        renderer = MessagingTileRenderer(context)
    )
}

现在,如果我们刷新预览,图片应如下所示:

功能块预览:包含 5 个按钮,这次在带有蓝色圆圈的两个按钮中显示了照片

在下一步中,我们将处理各个元素的点击操作。

9. 处理互动

我们可以在功能块上实现的最为实用的一项操作就是提供到关键用户历程的快捷方式。这与仅打开应用的应用启动器不同 - 在此处,我们有空间提供一些上下文快捷方式,让用户能够快捷访问应用中的特定屏幕。

到目前为止,我们针对条状标签和每个按钮均使用了 emptyClickable。对于不支持互动的预览,这种方式效果不错。现在,让我们来看看如何为这些元素添加操作。

“ActionBuilders”类中的两个构建器定义了可点击的操作:LoadActionLaunchAction

LoadAction

如果您希望在用户点击某个元素时在卡片服务中执行逻辑(例如,增加计数器),则可以使用 LoadAction

.setClickable(
    Clickable.Builder()
        .setId(ID_CLICK_INCREMENT_COUNTER)
        .setOnClick(ActionBuilders.LoadAction.Builder().build())
        .build()
    )
)

点击后,系统会在您的服务(CoroutinesTileService 中的 tileRequest)中调用 onTileRequest,因此这是刷新功能块界面的绝佳机会:

override suspend fun tileRequest(requestParams: TileRequest): Tile {
    if (requestParams.state.lastClickableId == ID_CLICK_INCREMENT_COUNTER) {
        // increment counter
    }
    // return an updated tile
}

LaunchAction

LaunchAction 可用于启动 activity。在 MessagingTileRenderer 中,更新搜索按钮的 Clickable。

搜索按钮由 MessagingTileRenderer 中的 searchLayout() 函数定义。它已经接受 Clickable 作为参数,但到目前为止,我们一直在传递 emptyClickable,这是一种空操作实现,它在用户点击按钮时不会执行任何操作。

我们来更新 messagingTileLayout(),使其传递真实的点击操作。添加 searchButtonClickable 参数并将其传递给 searchLayout()

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

private fun messagingTileLayout(
    context: Context,
    deviceParameters: DeviceParametersBuilders.DeviceParameters,
    state: MessagingTileState,
    searchButtonClickable: ModifiersBuilders.Clickable
...
    .addButtonContent(searchLayout(context, searchButtonClickable))

我们还需要更新 renderTile(我们在其中调用 messagingTileLayout),因为我们刚刚添加了一个新参数 (searchButtonClickable)。我们将使用 launchActivityClickable() 函数创建新的 Clickable,并传递 openSearch() ActionBuilder 作为操作:

start/src/main/java/com/example/wear/tiles/messaging/tile/MessagingTileRenderer.kt

override fun renderTile(
    state: MessagingTileState,
    deviceParameters: DeviceParametersBuilders.DeviceParameters
): LayoutElementBuilders.LayoutElement {
    return messagingTileLayout(
        context = context,
        deviceParameters = deviceParameters,
        state = state,
        searchButtonClickable = launchActivityClickable("search_button", openSearch())
    )
}

打开 launchActivityClickable 查看这些函数(已定义)的运作方式:

start/src/main/java/com/example/wear/tiles/messaging/tile/ClickableActions.kt

internal fun launchActivityClickable(
    clickableId: String,
    androidActivity: ActionBuilders.AndroidActivity
) = ModifiersBuilders.Clickable.Builder()
    .setId(clickableId)
    .setOnClick(
        ActionBuilders.LaunchAction.Builder()
            .setAndroidActivity(androidActivity)
            .build()
    )
    .build()

它与 LoadAction 非常相似,主要区别在于我们调用了 setAndroidActivity。在同一文件中,我们提供了各种 ActionBuilder.AndroidActivity 示例。

对于用于此 Clickable 的 openSearch,我们调用了 setMessagingActivity 并传递字符串 extra 来标识这是哪个按钮点击。

start/src/main/java/com/example/wear/tiles/messaging/tile/ClickableActions.kt

internal fun openSearch() = ActionBuilders.AndroidActivity.Builder()
    .setMessagingActivity()
    .addKeyToExtraMapping(
        MainActivity.EXTRA_JOURNEY,
        ActionBuilders.stringExtra(MainActivity.EXTRA_JOURNEY_SEARCH)
    )
    .build()

...

internal fun ActionBuilders.AndroidActivity.Builder.setMessagingActivity(): ActionBuilders.AndroidActivity.Builder {
    return setPackageName("com.example.wear.tiles")
        .setClassName("com.example.wear.tiles.messaging.MainActivity")
}

运行功能块并点击搜索按钮。系统应打开 MainActivity 并显示文本以确认已点击搜索按钮。

为其他卡片添加操作的方法也是类似的。ClickableActions 包含您需要的函数。如果您需要提示,请查看 finished 模块中的 MessagingTileRenderer

10. 恭喜

恭喜!您已了解如何构建适用于 Wear OS 的功能块!

后续操作

如需了解详情,请参阅 GitHub 上的 Golden Tiles 实现Wear OS 卡片指南