אחרי שתבקשו ותקבלו את ההרשאות הנדרשות, האפליקציה תוכל לגשת לחומרה של המשקפיים עם הממשק הקולי או המשקפיים עם התצוגה הפנימית. כדי לגשת לחומרה של המשקפיים (במקום לחומרה של הטלפון), צריך להשתמש בהקשר מושלך.
יש שתי דרכים עיקריות לקבל הקשר מוקרן, בהתאם למקום שבו הקוד מופעל:
קבלת הקשר מוצג אם הקוד פועל בפעילות מוצגת
אם הקוד של האפליקציה פועל מתוך הפעילות המוקרנת, הקשר הפעילות שלה הוא כבר הקשר מוקרן. בתרחיש הזה, קריאות שמתבצעות במסגרת הפעילות הזו כבר יכולות לגשת לחומרה של המשקפיים.
קבלת הקשר משוער לקוד שפועל ברכיב של אפליקציית טלפון
אם חלק מהאפליקציה מחוץ לפעילות המוקרנת (כמו פעילות בטלפון או שירות) צריך לגשת לחומרה של המשקפיים, הוא חייב לקבל במפורש הקשר מוקרן. כדי לעשות זאת, משתמשים ב-method 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, ההקשר המוקרן נשאר תקף למכשיר המחובר, והפעילות או השירות של האפליקציה בטלפון (כמו CameraManager) יכולים לגשת לחומרה של המשקפיים.
ניקוי בזמן הניתוק
ההקשר המוקרן קשור למחזור החיים של המכשיר המחובר, ולכן הוא נהרס כשהמכשיר מתנתק. כשהמכשיר מתנתק,
ProjectedContext.isProjectedDeviceConnected מחזיר את הערך false. האפליקציה צריכה להאזין לשינוי הזה ולנקות את כל שירותי המערכת (כמו CameraManager) או המשאבים שהאפליקציה יצרה באמצעות ההקשר המוקרן הזה.
הפעלה מחדש בזמן התחברות מחדש
כשהמשקפיים מתחברים מחדש, האפליקציה יכולה לקבל מופע נוסף של הקשר מוקרן באמצעות createProjectedDeviceContext, ואז להפעיל מחדש את כל שירותי המערכת או המשאבים באמצעות ההקשר המוקרן החדש.
הקלטת אודיו באמצעות המיקרופון של המשקפיים
אתם יכולים להקליט אודיו מהמשקפיים באמצעות פרופיל דיבורית (HFP) של Bluetooth. במצב הזה, המשקפיים מתחברים לטלפון באמצעות פרופילים רגילים של אוזניות ו-Advanced Audio Distribution Profile (A2DP), ופועלים כמו ציוד היקפי רגיל של Bluetooth. אם האפליקציה שלכם כבר מיועדת להקלטה רגילה ב-Bluetooth, אתם יכולים להשתמש בשיטה הזו כדי להקליט אודיו מהמשקפיים בלי פיתוח נוסף.
הקלטת אודיו באמצעות Bluetooth HFP
כדי להקליט אודיו באמצעות Bluetooth HFP, קודם צריך לבקש את ההרשאות הנדרשות בתחילת ההפעלה, ואז להקליט את האודיו באמצעות ה-API של AudioManager, כמו שמתואר בקטעים הבאים.
שליחת בקשה להרשאות
כמו בכל מכשיר אודיו רגיל עם Bluetooth, ההרשאות RECORD_AUDIO, BLUETOOTH_CONNECT והרשאות קשורות אחרות נשלטות על ידי הטלפון ולא על ידי המכשיר המחובר (כמו משקפיים עם ממשק קולי או משקפיים עם תצוגה פנימית).
כדי לבקש את ההרשאות:
מצהירים על ההרשאות הבאות בקובץ המניפסט של האפליקציה:
מבקשים את ההרשאות
RECORD_AUDIOו-BLUETOOTH_CONNECTבזמן הריצה באמצעות תהליך ההרשאות הרגיל של Android.
שימוש ב-AudioManager לניתוב אודיו
אחרי שהמשתמש נותן לאפליקציה את ההרשאות הנדרשות בתחילת ההפעלה, משתמשים ב-API AudioManager כדי להגדיר את מכשיר התקשורת ל-TYPE_BLUETOOTH_SCO, כדי לנתב את האודיו דרך Bluetooth HFP. כך המערכת מקבלת הוראה לאחזר אודיו מהציוד ההיקפי של ה-Bluetooth.
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() }
צילום תמונה באמצעות המצלמה של המשקפיים
כדי לצלם תמונה באמצעות מצלמת המשקפיים, מגדירים את תרחיש השימוש של ImageCapture ב-CameraX ומקשרים אותו למצלמת המשקפיים באמצעות ההקשר המתאים לאפליקציה:
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 Interop עם
Camera2CameraInfoכדי לקרוא את ה-CameraCharacteristics#SCALER_STREAM_CONFIGURATION_MAPהבסיסי, שיכול להיות שימושי לבדיקות מתקדמות ברזולוציות נתמכות. ResolutionSelectorבהתאמה אישית נוצר כדי לשלוט באופן מדויק ברזולוציית התמונה של הפלט ב-ImageCapture.- יצירת תרחיש לדוגמה של
ImageCaptureשמוגדר עםResolutionSelectorבהתאמה אישית. - מקשר את תרחיש השימוש של
ImageCaptureלמחזור החיים של הפעילות. כך הפתיחה והסגירה של המצלמה מנוהלות באופן אוטומטי על סמך מצב הפעילות (לדוגמה, עצירת המצלמה כשהפעילות מושהית). - הגדרה לחומרה עם סטרימינג יחיד: צינור עיבוד הנתונים של מצלמת המשקפיים מוגבל לסטרימינג פעיל אחד בכל פעם. כש-
ImageCaptureמקושר לבד, CameraX מצרף כברירת מחדל סטרימינג פנימי חוזר (MeteringRepeating) למיקוד ולמדידה, מה שגורם לפייפליין זורם של הסטרימינג היחיד להיתקע. כדי לקשר אתImageCaptureלבד בלי שידור מקדים, צריך להשבית את השידור החוזר הכפוי על ידי הגדרתsetRepeatingStreamForced(false)ב-CameraXConfig.Builder(לדוגמה, על ידי הטמעתCameraXConfig.ProviderבמחלקהApplicationאו הגדרתProcessCameraProviderלפני האתחול).
אחרי שמגדירים את המצלמה של המשקפיים, אפשר לצלם תמונה באמצעות המחלקה ImageCapture של CameraX. במסמכי התיעוד של CameraX מוסבר איך משתמשים ב-takePicture כדי לצלם תמונה.
צילום שידור באמצעות המצלמה של המשקפיים
כדי לצלם שידורים במקום תמונה, מחליפים את הרכיב ImageCapture מהדוגמה לצילום תמונה ברכיבים VideoCapture או ImageAnalysis ומשנים את לוגיקת הצילום.
מידע נוסף על ממשקי ה-API האלה ואיך להשתמש בהם זמין במסמכי התיעוד של צילום וידאו ב-CameraX וניתוח תמונות ב-CameraX.
בוחרים רזולוציה וקצב פריימים
כדאי לשלוח שאילתה למכשיר כדי לברר מהן הרזולוציות הנתמכות ומהם קצבי הפריימים המקסימליים שלהן. בטבלה הבאה מפורטים ערכים לדוגמה שאתם עשויים לראות, בהתאם לתרחיש השימוש של האפליקציה. כדי לחסוך בחשמל ולהפחית את החום במשקפיים, כדאי לבחור את הרזולוציה וקצב הפריימים הנמוכים ביותר שמתאימים לתרחיש השימוש שלכם.
| תרחיש שימוש | רזולוציה | קצב פריימים |
|---|---|---|
| זיהוי טקסט (OCR) או חישת העולם | 1920 x 1080 | פריים אחד לשנייה (FPS) |
| סטרימינג של וידאו באמצעות AI (עוזרים אישיים מולטי-מודאליים) | 1,024 x 768 | פריים אחד לשנייה (FPS) |
| לוקליזציה ומיקום חזותי (VPS) | 640 x 360 או 640 x 480 | 10 פריימים לשנייה (FPS) |
| שיחות וידאו | 640 x 360 או 640 x 480 | 24 FPS |
שאילתה לגבי קצב הפריימים המקסימלי לכל רזולוציה
CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES מדווח על גבולות קצב הפריימים הכולל של המכשיר, שיכול להיות גבוה יותר מקצב הסטרימינג שרזולוציה נתונה תומכת בו. כדי לקבל את הערך המקסימלי בפועל לכל רזולוציה במשקפיים עם אודיו ותצוגה, קוראים את משך הפריים המינימלי וממירים אותו ל-FPS באמצעות StreamConfigurationMap.getOutputMinFrameDuration(format, size):
@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,2] או [1,3] לשידור של 1 FPS, ו-[24,30] או [24,48] לשידור של 24 FPS.
הגדרת קצב הפריימים
כדי להגדיר את טווח קצב הפריימים בהתאם לתרחיש לדוגמה של 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 דוחה את ההגדרה לפני שהסטרימינג נפתח.
- בקשת קצב פריימים מעל המקסימום: אם תבקשו קצב פריימים מינימלי שגבוה מהמקסימום שנתמך ברזולוציה שלכם, הסשן עדיין ייפתח, אבל קצב הפריימים מהמשקפיים יוגבל למקסימום הנתמך, והגבול העליון ימשיך לפעול כמגבלת קצב ההשלמה. לדוגמה, בקשה של [30,60] ברזולוציה של 640 x 480 מגבילה את הטווח של קצב הפריימים ל-24 FPS (מתנהגת כמו [24,60]).
- האטה תרמית: כשהמשקפיים מתחממים, קצב הפריימים יורד אבל הרזולוציה נשארת זהה. כדאי לפתח את האפליקציה כך שתתמודד עם קצב פריימים נמוך או לא אחיד, למשל על ידי בדיקת חותמות הזמן של הפריימים במקום להניח שקצב הפריימים קבוע.
גישה לחומרה של הטלפון מפעילות חזויה
פעילות חזויה יכולה גם לגשת לחומרה של הטלפון (כמו המצלמה או המיקרופון) באמצעות 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 } }
כשניגשים לחומרה או למשאבים שספציפיים למכשיר המארח (הטלפון) באפליקציה היברידית (אפליקציה שכוללת גם חוויות בנייד וגם חוויות במשקפיים), צריך לבחור באופן מפורש את ההקשר הנכון כדי לוודא שהאפליקציה יכולה לגשת לחומרה הנכונה:
- משתמשים בהקשר
ActivityמהטלפוןActivityאו ב-ProjectedContext.createHostDeviceContextכדי לקבל את ההקשר של הטלפון. - אל תשתמשו ב-
getApplicationContextכי הקשר האפליקציה יכול להחזיר בטעות את ההקשר של המשקפיים אם הפעילות שהוקרנה הייתה הרכיב שהופעל לאחרונה.