使用投影上下文访问音频眼镜和显示眼镜上的硬件

适用的 XR 设备
本指南可帮助您为以下类型的 XR 设备打造优质体验。
音频和
显示眼镜

请求并获得必要权限后,您的应用便可以访问无屏眼镜或带屏眼镜上的硬件。访问眼镜硬件(而非手机硬件)的关键是使用投影上下文。

获取投影上下文主要有两种方式,具体取决于代码的执行位置:

如果您的代码在投射的 activity 中运行,请获取投射的上下文

如果应用的代码在投射的 activity 中运行,则其自己的 activity 上下文已经是投射的上下文。在这种情况下,在该 activity 中发出的调用已经可以访问眼镜的硬件。

获取在“电话”应用组件中运行的代码的投影上下文

如果应用中投影 activity 之外的部分(例如手机 activity 或服务)需要访问眼镜的硬件,则必须明确获取投影上下文。为此,请使用 createProjectedDeviceContext 方法:

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@OptIn(ExperimentalProjectedApi::class, ExperimentalCoroutinesApi::class)
private fun monitorProjectedConnectivity(activity: ComponentActivity) {
    activity.lifecycleScope.launch {
        // Before creating a projected context, check to see if the projected device is connected.
        // While this method returns true, the projected context remains valid.
        ProjectedContext.isProjectedDeviceConnected(activity, coroutineContext)
            .collectLatest { isConnected ->
                if (isConnected) {
                    // From a phone Activity or Service, get a context for the audio and display glasses.
                    // Re-initialize on reconnect: Obtain another context instance.
                    val projectedContext = try {
                        ProjectedContext.createProjectedDeviceContext(activity)
                    } catch (e: IllegalStateException) {
                        Log.e(TAG, "Failed to create projected context", e)
                        return@collectLatest
                    }

                    // Use the projectedContext to initialize system services (e.g., CameraManager).
                    Log.i(TAG, "Projected device connected. Initializing hardware...")
                } else {
                    // The projected context is destroyed when the device disconnects.
                    // Clean up on disconnect: Listen for 'false' and release resources.
                    Log.i(TAG, "Projected device disconnected. Cleaning up hardware resources...")
                }
            }
    }
}

检查有效性

将 createProjectedDeviceContext 调用封装在 ProjectedContext.isProjectedDeviceConnected 中。当此方法返回 true 时,投影上下文对已连接的设备仍然有效,并且您的“电话”应用 activity 或服务(例如 CameraManager)可以访问眼镜硬件。

断开连接时清理

投影上下文与已连接设备的生命周期相关联,因此会在设备断开连接时销毁。当设备断开连接时,ProjectedContext.isProjectedDeviceConnected 会返回 false。您的应用应监听此更改,并清理应用使用该投影上下文创建的所有系统服务(例如 CameraManager)或资源。

重新连接时重新初始化

当眼镜重新连接时,您的应用可以使用 createProjectedDeviceContext 获取另一个投影上下文实例,然后使用新的投影上下文重新初始化任何系统服务或资源。

使用眼镜的麦克风录制音频

您可以使用蓝牙免触摸配置文件 (HFP) 通过眼镜录制音频。 在此模式下,眼镜会使用标准耳机和高级音频分发配置文件 (A2DP) 连接到手机,其功能类似于典型的蓝牙外围设备。如果您的应用已设计为支持标准蓝牙录音,则您可以使用此方法从眼镜录制音频,而无需进行任何额外开发。

使用蓝牙 HFP 录制音频

如需使用蓝牙 HFP 录制音频,请先请求所需的运行时权限,然后使用 AudioManager API 录制音频,如以下部分所述。

请求权限

与任何标准蓝牙音频设备一样,RECORD_AUDIO、BLUETOOTH_CONNECT 和其他相关权限由手机控制,而不是由已连接的设备(例如无屏眼镜或带屏眼镜)控制。

请按照以下步骤请求权限:

  1. 在应用的清单文件中声明以下权限:

  2. 使用标准 Android 权限流程在运行时请求 RECORD_AUDIO 和 BLUETOOTH_CONNECT 权限。

使用 AudioManager 路由音频

在用户向您的应用授予必要的运行时权限后,请使用 AudioManager API 将通信设备设置为 TYPE_BLUETOOTH_SCO,以便通过蓝牙 HFP 路由音频。这会指示系统从蓝牙外围设备检索音频。

val audioManager = context.getSystemService(AudioManager::class.java) ?: return
val hfpDevice = audioManager.availableCommunicationDevices
    .firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO } ?: return

// HFP input is limited to 16 kHz, with mono or stereo channel configurations.
val sampleRate = 16_000
val channelMask = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT

// Use a small multiple of the minimum buffer size to keep chunks short for low latency.
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBufferSize <= 0) return

val audioRecord = AudioRecord.Builder()
    .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION)
    .setAudioFormat(
        AudioFormat.Builder()
            .setSampleRate(sampleRate)
            .setChannelMask(channelMask)
            .setEncoding(encoding)
            .build()
    )
    .setBufferSizeInBytes(minBufferSize * 2)
    .build()

try {
    // Route voice communication audio through the Bluetooth device.
    if (!audioManager.setCommunicationDevice(hfpDevice)) return
    audioRecord.startRecording()
    // Read audio with audioRecord.read() on a background thread.
} finally {
    // Stop and release when done.
    if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) audioRecord.stop()
    audioRecord.release()
    audioManager.clearCommunicationDevice()
}

使用眼镜的摄像头拍摄图片

如需使用眼镜的摄像头拍摄图片,请使用应用的正确上下文设置 CameraX 的 ImageCapture 用例并将其绑定到眼镜的摄像头:

private fun setUpGlassesCamera(activity: ComponentActivity) {
    activity.lifecycleScope.launch {
        // Before creating a projected context, check to see if the projected device is connected.
        ProjectedContext.isProjectedDeviceConnected(activity, coroutineContext)
            .collectLatest { isConnected ->
                if (isConnected) {
                    // 1. Get the CameraProvider using the projected context.
                    // When using the projected context, DEFAULT_BACK_CAMERA maps to the audio and display glasses' camera.
                    val projectedContext = try {
                        ProjectedContext.createProjectedDeviceContext(activity)
                    } catch (e: IllegalStateException) {
                        Log.e(TAG, "Projected context could not be created", e)
                        return@collectLatest
                    }

                    val cameraProviderFuture = ProcessCameraProvider.getInstance(projectedContext)

                    cameraProviderFuture.addListener({
                        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
                        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

                        // 2. Check for the presence of a camera.
                        if (!cameraProvider.hasCamera(cameraSelector)) {
                            Log.w(TAG, "The selected camera is not available.")
                            return@addListener
                        }

                        // 3. Query supported streaming resolutions dynamically using Camera2 Interop.
                        val cameraInfo = cameraProvider.getCameraInfo(cameraSelector)
                        val map = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
                            CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
                        )
                        val supportedResolutions = map?.getOutputSizes(ImageFormat.JPEG)?.toList() ?: emptyList()
                        Log.i(TAG, "Supported resolutions for JPEG: ${supportedResolutions.joinToString()}")

                        // 4. Dynamically select the best target resolution from the queried list.
                        val preferredResolution = Size(1920, 1080)
                        val dynamicTargetResolution = when {
                            // Case A: The preferred resolution is explicitly supported by the glasses.
                            supportedResolutions.contains(preferredResolution) -> preferredResolution

                            // Case B: Find the highest supported 16:9 resolution as a fallback.
                            else -> {
                                val targetAspectRatio = 16.0 / 9.0
                                val tolerance = 0.01
                                supportedResolutions
                                    .filter { abs((it.width.toDouble() / it.height) - targetAspectRatio) < tolerance }
                                    .maxByOrNull { it.width * it.height }
                                    // Case C: Absolute fallback if no 16:9 resolutions are found.
                                    ?: supportedResolutions.firstOrNull()
                                    ?: preferredResolution
                            }
                        }
                        Log.i(TAG, "Selected target resolution: $dynamicTargetResolution")

                        val resolutionStrategy = ResolutionStrategy(
                            dynamicTargetResolution,
                            ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
                        )
                        val resolutionSelector = ResolutionSelector.Builder()
                            .setResolutionStrategy(resolutionStrategy)
                            .build()

                        // 5. Initialize the ImageCapture use case with options.
                        val imageCapture = ImageCapture.Builder()
                            // Optional: Configure resolution, format, etc.
                            .setResolutionSelector(resolutionSelector)
                            .build()

                        try {
                            // Unbind use cases before rebinding.
                            cameraProvider.unbindAll()

                            // Bind use cases to camera using the Activity as the LifecycleOwner.
                            cameraProvider.bindToLifecycle(
                                activity,
                                cameraSelector,
                                imageCapture
                            )
                        } catch (exc: Exception) {
                            Log.e(TAG, "Use case binding failed", exc)
                        }
                    }, ContextCompat.getMainExecutor(activity))
                }
            }
    }
}

代码要点

  • 使用投影设备上下文获取 ProcessCameraProvider 的实例。
  • 在投影上下文的范围内,选择摄像头时,眼镜的主朝外摄像头会映射到 DEFAULT_BACK_CAMERA。
  • 绑定前检查会使用 cameraProvider.hasCamera(cameraSelector) 验证所选摄像头在设备上是否可用,然后再继续。
  • 将 Camera2 互操作与 Camera2CameraInfo 搭配使用,以读取底层 CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAP,这对于对支持的分辨率进行高级检查非常有用。
  • 自定义 ResolutionSelector 旨在精确控制 ImageCapture 的输出图片分辨率。
  • 创建配置了自定义 ResolutionSelector 的 ImageCapture 用例。
  • 将 ImageCapture 用例绑定到 activity 的生命周期。这会根据 activity 的状态自动管理相机的打开和关闭(例如,在 activity 暂停时停止相机)。
  • 针对单数据流硬件进行配置:眼镜摄像头流水线一次只能有一个活跃数据流。当 ImageCapture 单独绑定时,CameraX 默认会附加一个内部重复流 (MeteringRepeating) 用于对焦和测光,这会导致单流处理流水线停滞。如需单独绑定 ImageCapture 而不使用预览流,请通过在 CameraXConfig.Builder 上设置 setRepeatingStreamForced(false) 来停用强制重复流(例如,通过在 Application 类中实现 CameraXConfig.Provider 或在初始化之前配置 ProcessCameraProvider)。

设置好眼镜的摄像头后,您可以使用 CameraX 的 ImageCapture 类拍摄图片。如需了解如何使用 takePicture 拍摄图片,请参阅 CameraX 的文档。

使用眼镜的摄像头拍摄视频流

如需捕获视频流而非图片,请将捕获图片示例中的 ImageCapture 组件替换为 VideoCapture 或 ImageAnalysis 组件,并更改捕获逻辑。

如需详细了解这些 API 及其使用方法,请参阅 CameraX 视频拍摄和 CameraX 图片分析文档。

选择分辨率和帧速率

您应查询设备,了解其支持的分辨率和相应的最大帧速率。下表列出了您可能会看到的值的示例,具体取决于应用的使用场景。为节省电池电量并减少眼镜发热,请选择适合您应用场景的最低分辨率和帧速率。

使用场景 分辨率 帧速率
文本识别 (OCR) 或世界感知 1920 x 1080 1 FPS
AI 视频流式传输(多模态助理) 1024 x 768 1 FPS
本地化和视觉定位 (VPS) 640 x 360 或 640 x 480 10 FPS
视频通话 640 x 360 或 640 x 480 24 FPS

查询每个分辨率的最大帧速率

CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES 会报告设备的总体帧速率边界,该边界可能高于给定分辨率支持的流式传输速率。如需获取音频眼镜和显示眼镜上每种分辨率的实际最大值,请读取其最小帧时长,并使用 StreamConfigurationMap.getOutputMinFrameDuration(format, size) 将其转换为 FPS:

@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
fun getMaxFpsBySize(cameraInfo: CameraInfo, format: Int): Map<Size, Int> {
    val map = Camera2CameraInfo.from(cameraInfo).getCameraCharacteristic(
        CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP
    ) ?: return emptyMap()

    return map.getOutputSizes(format).associateWith { size ->
        val minFrameDurationNs = map.getOutputMinFrameDuration(format, size)
        if (minFrameDurationNs > 0) (1_000_000_000L / minFrameDurationNs).toInt() else 0
    }
}

接下来,使用该值计算目标帧速率范围的 minFps 和 maxFps 边界。将下限 (minFps) 限制为根据 getOutputMinFrameDuration 计算出的分辨率最大串流帧速率,并设置更高的上限 (maxFps) 作为追赶窗口:

val maxFpsBySize = getMaxFpsBySize(cameraInfo, ImageFormat.YUV_420_888)
val maxSupportedFps = maxFpsBySize[Size(640, 480)] ?: return ImageAnalysis.Builder().build()

// minFps is the target streaming frame rate and should be at most maxSupportedFps.
val minFps = minOf(desiredFps, maxSupportedFps)
// Set the upper bound to 2x minFps so the stream can catch up if frames are delayed.
val maxFps = minFps * 2

代码要点

  • 请使用与您的应用场景相符的格式。对于 ImageAnalysis,即 YUV_420_888。
  • 对于 VideoCapture,请改用 Class 重载:getOutputSizes(MediaRecorder::class.java) 和 getOutputMinFrameDuration(MediaRecorder::class.java, size)。
  • 请指定一个 maxFps 大于 minFps 的范围(例如 minFps*2),而不是将两个边界都设置为相同的值(例如 Range(minFps, minFps))。下限 (minFps) 用于设置目标串流帧速率,且最多应为根据 getOutputMinFrameDuration 计算出的值,而上限 (maxFps) 则充当延迟帧的追赶窗口,以防止持续延迟。例如,对于 1 FPS 的视频流,应使用 [1,2] 或 [1,3];对于 24 FPS 的视频流,应使用 [24,30] 或 [24,48]。

配置帧速率

如需根据 CameraX 用例配置帧速率范围,请执行以下操作:

// Configure Resolution Strategy for the stream
val resolutionStrategy = ResolutionStrategy(
    size,
    ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
)
val resolutionSelector = ResolutionSelector.Builder()
    .setResolutionStrategy(resolutionStrategy)
    .build()

// Create and return the ImageAnalysis builder with proper frame rate ranges
// ImageAnalysis: OCR, AI streaming, and VPS
val analysisBuilder = ImageAnalysis.Builder()
    .setResolutionSelector(resolutionSelector)

Camera2Interop.Extender(analysisBuilder)
    .setCaptureRequestOption(
        CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE,
        Range(minFps, maxFps)
    )
return analysisBuilder.build()

此示例展示了 VideoCapture 和 ImageAnalysis 的配置,但您可以实现应用支持的任何用例。

了解数据流的行为

使用捕获流时,请注意以下系统行为:

  • 不支持的分辨率会被拒绝:如果您请求的尺寸不在列表中,Camera2 会在数据流打开之前拒绝该配置。
  • 请求的帧速率高于上限:如果您请求的最低帧速率高于分辨率支持的上限,会话仍会打开,但眼镜的目标帧速率会被限制在该支持的上限,而上限会继续作为追赶速率限制。例如,在 640 x 480 分辨率下请求 [30,60] 会将目标帧速率限制为 24 FPS(行为类似于 [24,60])。
  • 热节流:当眼镜温度升高时,帧速率会下降,但分辨率保持不变。构建应用以处理较低或不均匀的帧速率,例如通过检查帧时间戳而不是假设固定速率。

通过投影 activity 访问手机的硬件

投射的 activity 还可以使用 createHostDeviceContext(context) 获取主机设备(手机)的上下文,从而访问手机的硬件(例如摄像头或麦克风):

@OptIn(ExperimentalProjectedApi::class)
private fun getPhoneContext(activity: ComponentActivity): Context? {
    return try {
        // From a projected Activity, get a context for the phone.
        ProjectedContext.createHostDeviceContext(activity)
    } catch (e: IllegalStateException) {
        Log.e(TAG, "Failed to create host device context", e)
        null
    }
}

在混合应用(同时包含移动设备和眼镜体验的应用)中访问主机设备(手机)特有的硬件或资源时,您必须明确选择正确的上下文,以确保应用可以访问正确的硬件: