控制外部设备

在 Android 11 及更高版本中,“快速访问设备控制”功能使用户能够快速查看和控制外部设备(例如灯光、恒温器和摄像头),只需从默认启动器进行三步交互即可。设备 OEM 选择他们使用的启动器。设备聚合器(例如 Google Home)和第三方供应商应用可以提供要在此空间中显示的设备。此页面将向您展示如何在该空间中显示设备控制并将其链接到您的控制应用。

图 1. Android UI 中的设备控制空间。

要添加此支持,请创建并声明一个 ControlsProviderService。根据预定义的控制类型创建您的应用支持的控制,然后为这些控制创建发布者。

用户界面

设备在“设备控制”下显示为模板化小部件。提供五个设备控制小部件,如下面的图所示

Toggle widget
切换
Toggle with slider widget
带滑块的切换
Range widget
范围(无法打开或关闭)
Stateless toggle widget
无状态切换
Temperature panel widget (closed)
温度面板(关闭)
图 2. 模板化小部件的集合。

触摸并按住小部件会将您带到应用以进行更深入的控制。您可以自定义每个小部件上的图标和颜色,但为了获得最佳的用户体验,如果默认设置与设备匹配,请使用默认图标和颜色。

An image showing the temperature panel widget (open)
图 3. 打开温度面板小部件。

创建服务

本节介绍如何创建 ControlsProviderService。此服务告诉 Android 系统 UI 您的应用包含必须在 Android UI 的“设备控制”区域中显示的设备控制。

ControlsProviderService API 假设熟悉反应流,如 Reactive Streams GitHub 项目 中所定义,并在 Java 9 Flow 接口 中实现。该 API 基于以下概念

  • 发布者:您的应用程序是发布者。
  • 订阅者:系统 UI 是订阅者,它可以请求发布者提供的多个控件。
  • 订阅:发布者可以向系统 UI 发送更新的时间范围。发布者或订阅者都可以关闭此窗口。

声明服务

您的应用必须在其应用清单中声明一个服务(例如 MyCustomControlService)。

该服务必须包含 ControlsProviderService 的意图过滤器。此过滤器允许应用程序向系统 UI 贡献控件。

您还需要一个在系统 UI 中的控件中显示的 label

以下示例显示了如何声明服务

<service
    android:name="MyCustomControlService"
    android:label="My Custom Controls"
    android:permission="android.permission.BIND_CONTROLS"
    android:exported="true"
    >
    <intent-filter>
      <action android:name="android.service.controls.ControlsProviderService" />
    </intent-filter>
</service>

接下来,创建一个名为 MyCustomControlService.kt 的新 Kotlin 文件,并使其扩展 ControlsProviderService()

Kotlin

    class MyCustomControlService : ControlsProviderService() {
        ...
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
        ...
    }
    

选择正确的控制类型

API 提供构建器方法来创建控件。要填充构建器,请确定您要控制的设备以及用户如何与之交互。执行以下步骤

  1. 选择控件表示的设备类型。DeviceTypes 类是所有受支持设备的枚举。该类型用于确定 UI 中设备的图标和颜色。
  2. 确定面向用户的名称、设备位置(例如厨房)以及与控件关联的其他 UI 文本元素。
  3. 选择最佳模板来支持用户交互。控件从应用程序分配一个 ControlTemplate。此模板直接向用户显示控件状态以及可用的输入方法,即 ControlAction。下表概述了一些可用的模板及其支持的操作
模板 操作 描述
ControlTemplate.getNoTemplateObject() 应用程序可能会使用此方法传达有关控件的信息,但用户无法与之交互。
ToggleTemplate BooleanAction 表示可以在启用和禁用状态之间切换的控件。BooleanAction 对象包含一个字段,当用户点击控件时,该字段会更改以表示请求的新状态。
RangeTemplate FloatAction 表示具有指定最小值、最大值和步长值的滑块小部件。当用户与滑块交互时,向应用程序发送一个新的 FloatAction 对象,其中包含更新的值。
ToggleRangeTemplate BooleanActionFloatAction 此模板是 ToggleTemplateRangeTemplate 的组合。它支持触摸事件以及滑块,例如控制可调光灯。
TemperatureControlTemplate ModeActionBooleanActionFloatAction 除了封装前面的操作之外,此模板还允许用户设置模式,例如加热、冷却、加热/冷却、节能或关闭。
StatelessTemplate CommandAction 用于指示提供触摸功能但无法确定其状态的控件,例如红外电视遥控器。您可以使用此模板定义例程或宏,它是控制和状态更改的聚合。

有了这些信息,您就可以创建控件了

例如,要控制智能灯泡和恒温器,请将以下常量添加到您的 MyCustomControlService

Kotlin

    private const val LIGHT_ID = 1234
    private const val LIGHT_TITLE = "My fancy light"
    private const val LIGHT_TYPE = DeviceTypes.TYPE_LIGHT
    private const val THERMOSTAT_ID = 5678
    private const val THERMOSTAT_TITLE = "My fancy thermostat"
    private const val THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT
 
    class MyCustomControlService : ControlsProviderService() {
      ...
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
 
    private final int LIGHT_ID = 1337;
    private final String LIGHT_TITLE = "My fancy light";
    private final int LIGHT_TYPE = DeviceTypes.TYPE_LIGHT;
    private final int THERMOSTAT_ID = 1338;
    private final String THERMOSTAT_TITLE = "My fancy thermostat";
    private final int THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT;
 
    ...
    }
    

为控件创建发布者

创建控件后,它需要一个发布者。发布者通知系统 UI 控件的存在。ControlsProviderService 类有两个发布者方法,您必须在应用程序代码中覆盖它们

  • createPublisherForAllAvailable():为应用中可用的所有控件创建 Publisher。使用 Control.StatelessBuilder() 为此发布者构建 Control 对象。
  • createPublisherFor():为给定控件列表创建 Publisher,这些控件由其字符串标识符标识。使用 Control.StatefulBuilder 构建这些 Control 对象,因为发布者必须为每个控件分配一个状态。

创建发布者

当您的应用首次将控件发布到系统 UI 时,应用不知道每个控件的状态。获取状态可能是一个耗时的操作,涉及设备提供商网络中的许多跳跃。使用 createPublisherForAllAvailable() 方法向系统宣传可用的控件。此方法使用 Control.StatelessBuilder 构建器类,因为每个控件的状态未知。

控件出现在 Android UI 后,用户可以选择收藏的控件。

要使用 Kotlin 协程创建 ControlsProviderService,请向您的 build.gradle 添加一个新的依赖项

Groovy

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4"
}

Kotlin

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4")
}

同步 Gradle 文件后,将以下代码段添加到您的 Service 中以实现 createPublisherForAllAvailable()

Kotlin

    class MyCustomControlService : ControlsProviderService() {
 
      override fun createPublisherForAllAvailable(): Flow.Publisher =
          flowPublish {
              send(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE))
              send(createStatelessControl(THERMOSTAT_ID, THERMOSTAT_TITLE, THERMOSTAT_TYPE))
          }
 
      private fun createStatelessControl(id: Int, title: String, type: Int): Control {
          val intent = Intent(this, MainActivity::class.java)
              .putExtra(EXTRA_MESSAGE, title)
              .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
          val action = PendingIntent.getActivity(
              this,
              id,
              intent,
              PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
          )
 
          return Control.StatelessBuilder(id.toString(), action)
              .setTitle(title)
              .setDeviceType(type)
              .build()
      }
 
          override fun createPublisherFor(controlIds: List): Flow.Publisher {
           TODO()
        }
 
        override fun performControlAction(
            controlId: String,
            action: ControlAction,
            consumer: Consumer
        ) {
            TODO()
        }
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
 
        private final int LIGHT_ID = 1337;
        private final String LIGHT_TITLE = "My fancy light";
        private final int LIGHT_TYPE = DeviceTypes.TYPE_LIGHT;
        private final int THERMOSTAT_ID = 1338;
        private final String THERMOSTAT_TITLE = "My fancy thermostat";
        private final int THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT;
 
        private boolean toggleState = false;
        private float rangeState = 18f;
        private final Map<String, ReplayProcessor> controlFlows = new HashMap<>();
 
        @NonNull
        @Override
        public Flow.Publisher createPublisherForAllAvailable() {
            List controls = new ArrayList<>();
            controls.add(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE));
            controls.add(createStatelessControl(THERMOSTAT_ID, THERMOSTAT_TITLE, THERMOSTAT_TYPE));
            return FlowAdapters.toFlowPublisher(Flowable.fromIterable(controls));
        }
 
        @NonNull
        @Override
        public Flow.Publisher createPublisherFor(@NonNull List controlIds) {
            ReplayProcessor updatePublisher = ReplayProcessor.create();
 
            controlIds.forEach(control -> {
                controlFlows.put(control, updatePublisher);
                updatePublisher.onNext(createLight());
                updatePublisher.onNext(createThermostat());
            });
 
            return FlowAdapters.toFlowPublisher(updatePublisher);
        }
    }
    

向下滑动系统菜单并找到“设备控制”按钮,如图 4 所示

An image showing the system ui for device controls
图 4. 系统菜单中的设备控件。

点击设备控件将导航到第二个屏幕,您可以在其中选择您的应用。选择应用后,您将看到之前的代码片段如何创建自定义系统菜单,显示您的新控件,如图 5 所示。

An image showing the system menu containing a light and thermostat control
图 5. 要添加的灯光和恒温器控件。

现在,实现 createPublisherFor() 方法,将以下内容添加到您的 Service

Kotlin

    private val job = SupervisorJob()
    private val scope = CoroutineScope(Dispatchers.IO + job)
    private val controlFlows = mutableMapOf<String, MutableSharedFlow>()
 
    private var toggleState = false
    private var rangeState = 18f
 
    override fun createPublisherFor(controlIds: List): Flow.Publisher {
        val flow = MutableSharedFlow(replay = 2, extraBufferCapacity = 2)
 
        controlIds.forEach { controlFlows[it] = flow }
 
        scope.launch {
            delay(1000) // Retrieving the toggle state.
            flow.tryEmit(createLight())
 
            delay(1000) // Retrieving the range state.
            flow.tryEmit(createThermostat())
 
        }
        return flow.asPublisher()
    }
 
    private fun createLight() = createStatefulControl(
        LIGHT_ID,
        LIGHT_TITLE,
        LIGHT_TYPE,
        toggleState,
        ToggleTemplate(
            LIGHT_ID.toString(),
            ControlButton(
                toggleState,
                toggleState.toString().uppercase(Locale.getDefault())
            )
        )
    )
 
    private fun createThermostat() = createStatefulControl(
        THERMOSTAT_ID,
        THERMOSTAT_TITLE,
        THERMOSTAT_TYPE,
        rangeState,
        RangeTemplate(
            THERMOSTAT_ID.toString(),
            15f,
            25f,
            rangeState,
            0.1f,
            "%1.1f"
        )
    )
 
    private fun  createStatefulControl(id: Int, title: String, type: Int, state: T, template: ControlTemplate): Control {
        val intent = Intent(this, MainActivity::class.java)
            .putExtra(EXTRA_MESSAGE, "$title $state")
            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        val action = PendingIntent.getActivity(
            this,
            id,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )
 
        return Control.StatefulBuilder(id.toString(), action)
            .setTitle(title)
            .setDeviceType(type)
            .setStatus(Control.STATUS_OK)
            .setControlTemplate(template)
            .build()
    }
 
    override fun onDestroy() {
        super.onDestroy()
        job.cancel()
    }
 
    

Java

    @NonNull
    @Override
    public Flow.Publisher createPublisherFor(@NonNull List controlIds) {
        ReplayProcessor updatePublisher = ReplayProcessor.create();
 
        controlIds.forEach(control -> {
            controlFlows.put(control, updatePublisher);
            updatePublisher.onNext(createLight());
            updatePublisher.onNext(createThermostat());
        });
 
        return FlowAdapters.toFlowPublisher(updatePublisher);
    }
 
    private Control createStatelessControl(int id, String title, int type) {
        Intent intent = new Intent(this, MainActivity.class)
                .putExtra(EXTRA_MESSAGE, title)
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        PendingIntent action = PendingIntent.getActivity(
                this,
                id,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );
 
        return new Control.StatelessBuilder(id + "", action)
                .setTitle(title)
                .setDeviceType(type)
                .build();
    }
 
    private Control createLight() {
        return createStatefulControl(
                LIGHT_ID,
                LIGHT_TITLE,
                LIGHT_TYPE,
                toggleState,
                new ToggleTemplate(
                        LIGHT_ID + "",
                        new ControlButton(
                                toggleState,
                                String.valueOf(toggleState).toUpperCase(Locale.getDefault())
                        )
                )
        );
    }
 
    private Control createThermostat() {
        return createStatefulControl(
                THERMOSTAT_ID,
                THERMOSTAT_TITLE,
                THERMOSTAT_TYPE,
                rangeState,
                new RangeTemplate(
                        THERMOSTAT_ID + "",
                        15f,
                        25f,
                        rangeState,
                        0.1f,
                        "%1.1f"
                )
        );
    }
 
    private  Control createStatefulControl(int id, String title, int type, T state, ControlTemplate template) {
        Intent intent = new Intent(this, MainActivity.class)
                .putExtra(EXTRA_MESSAGE, "$title $state")
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        PendingIntent action = PendingIntent.getActivity(
                this,
                id,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );
 
        return new Control.StatefulBuilder(id + "", action)
                .setTitle(title)
                .setDeviceType(type)
                .setStatus(Control.STATUS_OK)
                .setControlTemplate(template)
                .build();
    }
    

在此示例中,createPublisherFor() 方法包含了您的应用必须执行的操作的模拟实现:与您的设备通信以检索其状态,并将该状态发出到系统。

通过执行以下操作,createPublisherFor() 方法使用 Kotlin 协程和流来满足所需的 Reactive Streams API

  1. 创建一个 Flow
  2. 等待一秒钟。
  3. 创建并发出智能灯的状态。
  4. 再等待一秒钟。
  5. 创建并发出恒温器的状态。

处理操作

当用户与发布的控件交互时,performControlAction() 方法会发出信号。发送的 ControlAction 类型决定了操作。对给定的控件执行适当的操作,然后更新 Android UI 中设备的状态。

要完成此示例,请将以下内容添加到您的 Service

Kotlin

    override fun performControlAction(
        controlId: String,
        action: ControlAction,
        consumer: Consumer
    ) {
        controlFlows[controlId]?.let { flow ->
            when (controlId) {
                LIGHT_ID.toString() -> {
                    consumer.accept(ControlAction.RESPONSE_OK)
                    if (action is BooleanAction) toggleState = action.newState
                    flow.tryEmit(createLight())
                }
                THERMOSTAT_ID.toString() -> {
                    consumer.accept(ControlAction.RESPONSE_OK)
                    if (action is FloatAction) rangeState = action.newValue
                    flow.tryEmit(createThermostat())
                }
                else -> consumer.accept(ControlAction.RESPONSE_FAIL)
            }
        } ?: consumer.accept(ControlAction.RESPONSE_FAIL)
    }
    

Java

    @Override
    public void performControlAction(@NonNull String controlId, @NonNull ControlAction action, @NonNull Consumer consumer) {
        ReplayProcessor processor = controlFlows.get(controlId);
        if (processor == null) return;
 
        if (controlId.equals(LIGHT_ID + "")) {
            consumer.accept(ControlAction.RESPONSE_OK);
            if (action instanceof BooleanAction) toggleState = ((BooleanAction) action).getNewState();
            processor.onNext(createLight());
        }
        if (controlId.equals(THERMOSTAT_ID + "")) {
            consumer.accept(ControlAction.RESPONSE_OK);
            if (action instanceof FloatAction) rangeState = ((FloatAction) action).getNewValue()
            processor.onNext(createThermostat());
        }
    }
    

运行应用,访问设备控件菜单,查看您的灯光和恒温器控件。

An image showing a light and thermostat control
图 6. 灯光和恒温器控件。