1. 项目背景与核心需求在移动应用开发中扫码功能已经成为刚需。传统方案往往面临两个痛点一是全图识别导致性能浪费二是用户难以精准对准目标。我在实际项目中就遇到过这样的问题——用户抱怨扫码反应慢而开发团队则苦恼于手机发热和耗电问题。MLKit的条码识别能力配合CameraX的相机流处理理论上能解决这些问题。但关键在于如何将Compose界面中定义的扫描框ROI精确映射到CameraX的图像分析流。这需要处理三个技术难点坐标系转换UI坐标 ↔ 相机帧坐标图像裁剪的性能优化识别结果与UI的实时同步2. 环境搭建与基础配置2.1 添加必要依赖在app/build.gradle中添加以下依赖项// CameraX核心库 def camerax_version 1.3.0 implementation androidx.camera:camera-core:${camerax_version} implementation androidx.camera:camera-camera2:${camerax_version} implementation androidx.camera:camera-lifecycle:${camerax_version} implementation androidx.camera:camera-view:${camerax_version} // MLKit条码识别 implementation com.google.mlkit:barcode-scanning:17.2.0 // Compose集成 implementation androidx.camera:camera-viewfinder-compose:1.3.02.2 相机权限配置在AndroidManifest.xml中添加权限声明uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera / uses-feature android:nameandroid.hardware.camera.autofocus /建议使用accompanist-permissions库处理动态权限申请这里给出Compose中的权限检查示例Composable fun CheckCameraPermission() { val permissionState rememberPermissionState(android.Manifest.permission.CAMERA) LaunchedEffect(Unit) { if (!permissionState.hasPermission) { permissionState.launchPermissionRequest() } } if (!permissionState.hasPermission) { PermissionRequiredUI() } else { MainScannerScreen() } }3. 核心架构设计3.1 组件关系图整个系统由三个核心模块构成闭环UI层Compose绘制扫描框和动画效果协调层处理坐标转换和状态管理识别层CameraX提供图像流MLKit执行识别┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Compose │ ←→ │ Coordinator│ ←→ │ CameraXML │ │ UI │ │ (ViewModel) │ │ Kit │ └─────────────┘ └─────────────┘ └─────────────┘3.2 关键数据流用户打开界面时初始化相机每帧图像经过ROI区域裁剪MLKit识别结果通过ViewModel回传Compose根据状态更新UI4. 实现ROI精准识别4.1 扫描框UI实现使用Canvas绘制带扫描动画的矩形框Composable fun ScanFrame(modifier: Modifier Modifier) { val transition rememberInfiniteTransition() val animatePos transition.animateFloat( initialValue 0f, targetValue 1f, animationSpec infiniteRepeatable( animation tween(1500), repeatMode RepeatMode.Reverse ) ) Canvas(modifier.fillMaxSize()) { // 半透明背景 drawRect(Color.Black.copy(alpha 0.6f)) // 扫描框区域居中占屏幕宽度80% val frameWidth size.width * 0.8f val frameHeight frameWidth * 0.6f val frameRect Rect( offset Offset( (size.width - frameWidth) / 2, (size.height - frameHeight) / 2 ), size Size(frameWidth, frameHeight) ) // 透明扫描框 drawRect(Color.Transparent, frameRect, blendMode BlendMode.Clear) // 扫描线动画 val lineY frameRect.top animatePos.value * frameHeight drawLine( color Color.Green, start Offset(frameRect.left 10.dp.toPx(), lineY), end Offset(frameRect.right - 10.dp.toPx(), lineY), strokeWidth 3.dp.toPx() ) // 四角装饰 val cornerLength 20.dp.toPx() fun drawCorner(start: Offset, end: Offset) { drawLine(Color.Green, start, end, 3.dp.toPx()) } // 左上角 drawCorner(frameRect.topLeft, frameRect.topLeft Offset(cornerLength, 0f)) drawCorner(frameRect.topLeft, frameRect.topLeft Offset(0f, cornerLength)) // 其他三个角类似实现... } }4.2 坐标转换关键算法这是整个项目的技术难点需要处理屏幕坐标系与图像坐标系的映射设备旋转方向的适配不同分辨率设备的比例计算class CoordinateTransformer( private val viewSize: IntSize, private val imageSize: IntSize, private val rotationDegrees: Int ) { fun screenToImage(screenRect: Rect): Rect { return when (rotationDegrees) { 0 - transform0(screenRect) 90 - transform90(screenRect) 180 - transform180(screenRect) 270 - transform270(screenRect) else - screenRect }.also { // 确保不越界 it.left.coerceIn(0f, imageSize.width.toFloat()) it.top.coerceIn(0f, imageSize.height.toFloat()) } } private fun transform0(screenRect: Rect): Rect { val scaleX imageSize.width.toFloat() / viewSize.width val scaleY imageSize.height.toFloat() / viewSize.height return Rect( screenRect.left * scaleX, screenRect.top * scaleY, screenRect.right * scaleX, screenRect.bottom * scaleY ) } private fun transform90(screenRect: Rect): Rect { val scaleX imageSize.width.toFloat() / viewSize.height val scaleY imageSize.height.toFloat() / viewSize.width return Rect( (viewSize.height - screenRect.bottom) * scaleX, screenRect.left * scaleY, (viewSize.height - screenRect.top) * scaleX, screenRect.right * scaleY ) } // 其他旋转角度类似实现... }4.3 图像分析器实现创建自定义的ImageAnalysis.Analyzerclass BarcodeAnalyzer( private val viewModel: ScanViewModel, private val roiRect: Rect ) : ImageAnalysis.Analyzer { private val scanner BarcodeScanning.getClient() private var lastScanTime 0L OptIn(ExperimentalGetImage::class) override fun analyze(imageProxy: ImageProxy) { if (System.currentTimeMillis() - lastScanTime 300) { imageProxy.close() return } val mediaImage imageProxy.image ?: return val imageSize IntSize(mediaImage.width, mediaImage.height) // 坐标转换 val transformer CoordinateTransformer( viewSize viewModel.uiState.viewSize, imageSize imageSize, rotationDegrees imageProxy.imageInfo.rotationDegrees ) val imageRect transformer.screenToImage(roiRect) // 图像裁剪 val croppedImage try { val cropRect android.graphics.Rect( imageRect.left.toInt(), imageRect.top.toInt(), imageRect.right.toInt(), imageRect.bottom.toInt() ) YuvToRgbConverter(imageProxy.image!!).crop(cropRect) } catch (e: Exception) { imageProxy.close() return } // MLKit识别 val inputImage InputImage.fromBitmap(croppedImage, imageProxy.imageInfo.rotationDegrees) scanner.process(inputImage) .addOnSuccessListener { barcodes - barcodes.firstOrNull()?.rawValue?.let { result - viewModel.onBarcodeScanned(result) lastScanTime System.currentTimeMillis() } } .addOnCompleteListener { imageProxy.close() croppedImage.recycle() } } }5. 性能优化实践5.1 识别频率控制通过时间戳控制识别频率避免过度消耗资源// 在Analyzer中添加时间判断 private var lastProcessTime 0L override fun analyze(imageProxy: ImageProxy) { val currentTime System.currentTimeMillis() if (currentTime - lastProcessTime 300) { // 300ms间隔 imageProxy.close() return } lastProcessTime currentTime // ...剩余处理逻辑 }5.2 图像处理优化使用YUV直接裁剪避免全图转换class YuvToRgbConverter(private val yuvImage: Image) { fun crop(rect: Rect): Bitmap { val yBuffer yuvImage.planes[0].buffer val uBuffer yuvImage.planes[1].buffer val vBuffer yuvImage.planes[2].buffer // 实际实现需要考虑YUV格式和padding处理 // 这里简化为伪代码 return Bitmap.createBitmap( rect.width(), rect.height(), Bitmap.Config.ARGB_8888 ).apply { // YUV数据拷贝和转换... } } }5.3 内存管理确保及时释放资源.addOnCompleteListener { imageProxy.close() croppedImage?.recycle() // 显式回收Bitmap }6. 完整示例代码6.1 主界面实现Composable fun BarcodeScannerScreen() { val context LocalContext.current val lifecycleOwner LocalLifecycleOwner.current val viewModel: ScanViewModel viewModel() // 获取屏幕尺寸 val configuration LocalConfiguration.current val screenSize remember(configuration) { IntSize(configuration.screenWidthDp, configuration.screenHeightDp) } Box(modifier Modifier.fillMaxSize()) { // 相机预览 val previewView remember { PreviewView(context) } AndroidView( factory { previewView }, modifier Modifier.fillMaxSize() ) { view - val cameraProviderFuture ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener({ val cameraProvider cameraProviderFuture.get() val preview Preview.Builder().build().apply { setSurfaceProvider(view.surfaceProvider) } val analysis ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer( ContextCompat.getMainExecutor(context), BarcodeAnalyzer(viewModel, viewModel.uiState.roiRect) ) } try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis ) } catch(e: Exception) { Log.e(Camera, Use case binding failed, e) } }, ContextCompat.getMainExecutor(context)) } // 扫描框UI ScanFrame() // 识别结果 when(val state viewModel.uiState.scanState) { is ScanState.Success - { ResultPopup(state.text) { viewModel.resetState() } } is ScanState.Error - { ErrorToast(state.message) } else - Unit } } }6.2 ViewModel实现class ScanViewModel : ViewModel() { data class UiState( val viewSize: IntSize IntSize.Zero, val roiRect: Rect Rect.Zero, val scanState: ScanState ScanState.Idle ) var uiState by mutableStateOf(UiState()) private set fun onViewSizeChanged(size: IntSize) { val centerX size.width / 2f val centerY size.height / 2f val frameWidth size.width * 0.8f val frameHeight frameWidth * 0.6f uiState uiState.copy( viewSize size, roiRect Rect( centerX - frameWidth/2, centerY - frameHeight/2, centerX frameWidth/2, centerY frameHeight/2 ) ) } fun onBarcodeScanned(text: String) { uiState uiState.copy( scanState ScanState.Success(text) ) } fun resetState() { uiState uiState.copy( scanState ScanState.Idle ) } } sealed class ScanState { object Idle : ScanState() data class Success(val text: String) : ScanState() data class Error(val message: String) : ScanState() }7. 常见问题解决方案7.1 识别区域偏移问题现象识别框与实际识别区域不匹配解决方案检查坐标转换时是否考虑了设备旋转验证viewSize是否准确传递添加调试日志输出转换前后的坐标值// 在CoordinateTransformer中添加调试输出 Log.d(CoordDebug, View: $viewSize, Image: $imageSize) Log.d(CoordDebug, ScreenRect: $screenRect - ImageRect: ${transform0(screenRect)})7.2 低端设备卡顿优化方案降低图像分析分辨率ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) // HD分辨率 .build()使用STRATEGY_KEEP_ONLY_LATEST策略增加识别间隔时间如500ms7.3 多码同屏处理修改识别逻辑处理多个条码scanner.process(inputImage) .addOnSuccessListener { barcodes - val results barcodes.mapNotNull { it.rawValue } if (results.isNotEmpty()) { viewModel.onBarcodeScanned(results) } }8. 扩展功能实现8.1 自动缩放功能当条码距离较远时自动放大// 在Analyzer中检测条码尺寸 val barcodeSize barcode.boundingBox?.width() ?: 0 if (barcodeSize minSizeThreshold) { camera.cameraControl.setZoomRatio(zoomLevel) }8.2 历史记录功能在ViewModel中添加存储逻辑private val _scanHistory mutableStateListOfString() val scanHistory: ListString get() _scanHistory fun onBarcodeScanned(text: String) { _scanHistory.add(0, text) // 最新记录放前面 // ...原有逻辑 }8.3 自定义识别类型配置MLKit只识别特定类型val options BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) .build() val scanner BarcodeScanning.getClient(options)
【Android】实战智能扫码:Compose + MLKit + CameraX 实现ROI精准识别
发布时间:2026/7/15 5:21:42
1. 项目背景与核心需求在移动应用开发中扫码功能已经成为刚需。传统方案往往面临两个痛点一是全图识别导致性能浪费二是用户难以精准对准目标。我在实际项目中就遇到过这样的问题——用户抱怨扫码反应慢而开发团队则苦恼于手机发热和耗电问题。MLKit的条码识别能力配合CameraX的相机流处理理论上能解决这些问题。但关键在于如何将Compose界面中定义的扫描框ROI精确映射到CameraX的图像分析流。这需要处理三个技术难点坐标系转换UI坐标 ↔ 相机帧坐标图像裁剪的性能优化识别结果与UI的实时同步2. 环境搭建与基础配置2.1 添加必要依赖在app/build.gradle中添加以下依赖项// CameraX核心库 def camerax_version 1.3.0 implementation androidx.camera:camera-core:${camerax_version} implementation androidx.camera:camera-camera2:${camerax_version} implementation androidx.camera:camera-lifecycle:${camerax_version} implementation androidx.camera:camera-view:${camerax_version} // MLKit条码识别 implementation com.google.mlkit:barcode-scanning:17.2.0 // Compose集成 implementation androidx.camera:camera-viewfinder-compose:1.3.02.2 相机权限配置在AndroidManifest.xml中添加权限声明uses-permission android:nameandroid.permission.CAMERA / uses-feature android:nameandroid.hardware.camera / uses-feature android:nameandroid.hardware.camera.autofocus /建议使用accompanist-permissions库处理动态权限申请这里给出Compose中的权限检查示例Composable fun CheckCameraPermission() { val permissionState rememberPermissionState(android.Manifest.permission.CAMERA) LaunchedEffect(Unit) { if (!permissionState.hasPermission) { permissionState.launchPermissionRequest() } } if (!permissionState.hasPermission) { PermissionRequiredUI() } else { MainScannerScreen() } }3. 核心架构设计3.1 组件关系图整个系统由三个核心模块构成闭环UI层Compose绘制扫描框和动画效果协调层处理坐标转换和状态管理识别层CameraX提供图像流MLKit执行识别┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Compose │ ←→ │ Coordinator│ ←→ │ CameraXML │ │ UI │ │ (ViewModel) │ │ Kit │ └─────────────┘ └─────────────┘ └─────────────┘3.2 关键数据流用户打开界面时初始化相机每帧图像经过ROI区域裁剪MLKit识别结果通过ViewModel回传Compose根据状态更新UI4. 实现ROI精准识别4.1 扫描框UI实现使用Canvas绘制带扫描动画的矩形框Composable fun ScanFrame(modifier: Modifier Modifier) { val transition rememberInfiniteTransition() val animatePos transition.animateFloat( initialValue 0f, targetValue 1f, animationSpec infiniteRepeatable( animation tween(1500), repeatMode RepeatMode.Reverse ) ) Canvas(modifier.fillMaxSize()) { // 半透明背景 drawRect(Color.Black.copy(alpha 0.6f)) // 扫描框区域居中占屏幕宽度80% val frameWidth size.width * 0.8f val frameHeight frameWidth * 0.6f val frameRect Rect( offset Offset( (size.width - frameWidth) / 2, (size.height - frameHeight) / 2 ), size Size(frameWidth, frameHeight) ) // 透明扫描框 drawRect(Color.Transparent, frameRect, blendMode BlendMode.Clear) // 扫描线动画 val lineY frameRect.top animatePos.value * frameHeight drawLine( color Color.Green, start Offset(frameRect.left 10.dp.toPx(), lineY), end Offset(frameRect.right - 10.dp.toPx(), lineY), strokeWidth 3.dp.toPx() ) // 四角装饰 val cornerLength 20.dp.toPx() fun drawCorner(start: Offset, end: Offset) { drawLine(Color.Green, start, end, 3.dp.toPx()) } // 左上角 drawCorner(frameRect.topLeft, frameRect.topLeft Offset(cornerLength, 0f)) drawCorner(frameRect.topLeft, frameRect.topLeft Offset(0f, cornerLength)) // 其他三个角类似实现... } }4.2 坐标转换关键算法这是整个项目的技术难点需要处理屏幕坐标系与图像坐标系的映射设备旋转方向的适配不同分辨率设备的比例计算class CoordinateTransformer( private val viewSize: IntSize, private val imageSize: IntSize, private val rotationDegrees: Int ) { fun screenToImage(screenRect: Rect): Rect { return when (rotationDegrees) { 0 - transform0(screenRect) 90 - transform90(screenRect) 180 - transform180(screenRect) 270 - transform270(screenRect) else - screenRect }.also { // 确保不越界 it.left.coerceIn(0f, imageSize.width.toFloat()) it.top.coerceIn(0f, imageSize.height.toFloat()) } } private fun transform0(screenRect: Rect): Rect { val scaleX imageSize.width.toFloat() / viewSize.width val scaleY imageSize.height.toFloat() / viewSize.height return Rect( screenRect.left * scaleX, screenRect.top * scaleY, screenRect.right * scaleX, screenRect.bottom * scaleY ) } private fun transform90(screenRect: Rect): Rect { val scaleX imageSize.width.toFloat() / viewSize.height val scaleY imageSize.height.toFloat() / viewSize.width return Rect( (viewSize.height - screenRect.bottom) * scaleX, screenRect.left * scaleY, (viewSize.height - screenRect.top) * scaleX, screenRect.right * scaleY ) } // 其他旋转角度类似实现... }4.3 图像分析器实现创建自定义的ImageAnalysis.Analyzerclass BarcodeAnalyzer( private val viewModel: ScanViewModel, private val roiRect: Rect ) : ImageAnalysis.Analyzer { private val scanner BarcodeScanning.getClient() private var lastScanTime 0L OptIn(ExperimentalGetImage::class) override fun analyze(imageProxy: ImageProxy) { if (System.currentTimeMillis() - lastScanTime 300) { imageProxy.close() return } val mediaImage imageProxy.image ?: return val imageSize IntSize(mediaImage.width, mediaImage.height) // 坐标转换 val transformer CoordinateTransformer( viewSize viewModel.uiState.viewSize, imageSize imageSize, rotationDegrees imageProxy.imageInfo.rotationDegrees ) val imageRect transformer.screenToImage(roiRect) // 图像裁剪 val croppedImage try { val cropRect android.graphics.Rect( imageRect.left.toInt(), imageRect.top.toInt(), imageRect.right.toInt(), imageRect.bottom.toInt() ) YuvToRgbConverter(imageProxy.image!!).crop(cropRect) } catch (e: Exception) { imageProxy.close() return } // MLKit识别 val inputImage InputImage.fromBitmap(croppedImage, imageProxy.imageInfo.rotationDegrees) scanner.process(inputImage) .addOnSuccessListener { barcodes - barcodes.firstOrNull()?.rawValue?.let { result - viewModel.onBarcodeScanned(result) lastScanTime System.currentTimeMillis() } } .addOnCompleteListener { imageProxy.close() croppedImage.recycle() } } }5. 性能优化实践5.1 识别频率控制通过时间戳控制识别频率避免过度消耗资源// 在Analyzer中添加时间判断 private var lastProcessTime 0L override fun analyze(imageProxy: ImageProxy) { val currentTime System.currentTimeMillis() if (currentTime - lastProcessTime 300) { // 300ms间隔 imageProxy.close() return } lastProcessTime currentTime // ...剩余处理逻辑 }5.2 图像处理优化使用YUV直接裁剪避免全图转换class YuvToRgbConverter(private val yuvImage: Image) { fun crop(rect: Rect): Bitmap { val yBuffer yuvImage.planes[0].buffer val uBuffer yuvImage.planes[1].buffer val vBuffer yuvImage.planes[2].buffer // 实际实现需要考虑YUV格式和padding处理 // 这里简化为伪代码 return Bitmap.createBitmap( rect.width(), rect.height(), Bitmap.Config.ARGB_8888 ).apply { // YUV数据拷贝和转换... } } }5.3 内存管理确保及时释放资源.addOnCompleteListener { imageProxy.close() croppedImage?.recycle() // 显式回收Bitmap }6. 完整示例代码6.1 主界面实现Composable fun BarcodeScannerScreen() { val context LocalContext.current val lifecycleOwner LocalLifecycleOwner.current val viewModel: ScanViewModel viewModel() // 获取屏幕尺寸 val configuration LocalConfiguration.current val screenSize remember(configuration) { IntSize(configuration.screenWidthDp, configuration.screenHeightDp) } Box(modifier Modifier.fillMaxSize()) { // 相机预览 val previewView remember { PreviewView(context) } AndroidView( factory { previewView }, modifier Modifier.fillMaxSize() ) { view - val cameraProviderFuture ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener({ val cameraProvider cameraProviderFuture.get() val preview Preview.Builder().build().apply { setSurfaceProvider(view.surfaceProvider) } val analysis ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { it.setAnalyzer( ContextCompat.getMainExecutor(context), BarcodeAnalyzer(viewModel, viewModel.uiState.roiRect) ) } try { cameraProvider.unbindAll() cameraProvider.bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis ) } catch(e: Exception) { Log.e(Camera, Use case binding failed, e) } }, ContextCompat.getMainExecutor(context)) } // 扫描框UI ScanFrame() // 识别结果 when(val state viewModel.uiState.scanState) { is ScanState.Success - { ResultPopup(state.text) { viewModel.resetState() } } is ScanState.Error - { ErrorToast(state.message) } else - Unit } } }6.2 ViewModel实现class ScanViewModel : ViewModel() { data class UiState( val viewSize: IntSize IntSize.Zero, val roiRect: Rect Rect.Zero, val scanState: ScanState ScanState.Idle ) var uiState by mutableStateOf(UiState()) private set fun onViewSizeChanged(size: IntSize) { val centerX size.width / 2f val centerY size.height / 2f val frameWidth size.width * 0.8f val frameHeight frameWidth * 0.6f uiState uiState.copy( viewSize size, roiRect Rect( centerX - frameWidth/2, centerY - frameHeight/2, centerX frameWidth/2, centerY frameHeight/2 ) ) } fun onBarcodeScanned(text: String) { uiState uiState.copy( scanState ScanState.Success(text) ) } fun resetState() { uiState uiState.copy( scanState ScanState.Idle ) } } sealed class ScanState { object Idle : ScanState() data class Success(val text: String) : ScanState() data class Error(val message: String) : ScanState() }7. 常见问题解决方案7.1 识别区域偏移问题现象识别框与实际识别区域不匹配解决方案检查坐标转换时是否考虑了设备旋转验证viewSize是否准确传递添加调试日志输出转换前后的坐标值// 在CoordinateTransformer中添加调试输出 Log.d(CoordDebug, View: $viewSize, Image: $imageSize) Log.d(CoordDebug, ScreenRect: $screenRect - ImageRect: ${transform0(screenRect)})7.2 低端设备卡顿优化方案降低图像分析分辨率ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) // HD分辨率 .build()使用STRATEGY_KEEP_ONLY_LATEST策略增加识别间隔时间如500ms7.3 多码同屏处理修改识别逻辑处理多个条码scanner.process(inputImage) .addOnSuccessListener { barcodes - val results barcodes.mapNotNull { it.rawValue } if (results.isNotEmpty()) { viewModel.onBarcodeScanned(results) } }8. 扩展功能实现8.1 自动缩放功能当条码距离较远时自动放大// 在Analyzer中检测条码尺寸 val barcodeSize barcode.boundingBox?.width() ?: 0 if (barcodeSize minSizeThreshold) { camera.cameraControl.setZoomRatio(zoomLevel) }8.2 历史记录功能在ViewModel中添加存储逻辑private val _scanHistory mutableStateListOfString() val scanHistory: ListString get() _scanHistory fun onBarcodeScanned(text: String) { _scanHistory.add(0, text) // 最新记录放前面 // ...原有逻辑 }8.3 自定义识别类型配置MLKit只识别特定类型val options BarcodeScannerOptions.Builder() .setBarcodeFormats(Barcode.FORMAT_QR_CODE) .build() val scanner BarcodeScanning.getClient(options)