控制外部设备

在 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 BooleanAction, FloatAction 此模板是 ToggleTemplateRangeTemplate 的组合。它支持触摸事件和滑块,例如控制可调光灯。
TemperatureControlTemplate ModeAction, BooleanAction, FloatAction 除了封装上述操作外,此模板还允许用户设置模式,例如制热、制冷、制热/制冷、节能或关闭。
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. 灯光和恒温器控制。