Z tego ćwiczenia dowiesz się, jak utworzyć aplikację aparatu, która korzysta z aparatu X do wyświetlania wizjera, robienia zdjęć i analizowania obrazu z kamery.
W tym celu wprowadzimy koncepcję przypadków użycia w Aparacie X. Można ich używać do wykonywania różnych operacji związanych z aparatem – od wyświetlania wizjera po analizowanie klatek w czasie rzeczywistym.
Czego się nauczysz
- Jak dodać zależności aparatu X.
- Jak wyświetlić podgląd z aparatu w aktywności. (Przypadek użycia podglądu)
- Jak zrobić zdjęcie i zapisać je w pamięci. (Przypadek użycia programu ImageCapture)
- Jak analizować klatki z aparatu w czasie rzeczywistym. (Przypadek użycia narzędzia ImageAnalysis)
Potrzebujemy sprzętu
- Urządzenie z Androidem, ale można też użyć emulatora Android Studio. Minimalny obsługiwany poziom interfejsu API to 21.
Oprogramowanie, którego będziemy potrzebować
- Android Studio 3.3 lub nowszy.
W menu Android Studio rozpocznij nowy projekt i po wyświetleniu monitu wybierz Pusta aktywność.
Potem możemy wybrać dowolną nazwę – głównie wybraliśmy „Aparat X”. Musimy mieć pewność, że językiem języka jest Kotlin, a minimalny poziom interfejsu API – 21 (co jest minimalne dla Aparatu X) i używamy artefaktów Androida X.
Zacznijmy od zależności zależności Aparatu do pliku Gradle aplikacji w sekcji Zależności:
// Use the most recent version of CameraX, currently that is alpha04
def camerax_version = "1.0.0-alpha04"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
Gdy pojawi się komunikat, kliknij Sync Now (Synchronizuj teraz), a my uruchomimy aplikację Aparat X.
Do wyświetlania wizjera aparatu użyjemy SurfaceTexture. W tym ćwiczeniu wyświetlimy wizjer w formacie kwadratowym o stałym rozmiarze. Bardziej wyczerpujący przykład pokazujący elastyczny wizjer umożliwia wyświetlenie oficjalnej próbki.
Zmodyfikuj plik układu activity_main w sekcji > układ > activity_main.xml:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextureView
android:id="@+id/view_finder"
android:layout_width="640px"
android:layout_height="640px"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Bardzo ważnym elementem dodawania do projektu funkcji, które korzystają z aparatu, jest prośba o odpowiednie uprawnienia CAMERA. Najpierw musimy zadeklarować te elementy w manifeście, przed tagiem aplikacji:
<uses-permission android:name="android.permission.CAMERA" />
Następnie w ramach MainActivity musisz poprosić o uprawnienia w czasie działania. Zostaną wprowadzone zmiany w pliku MainActivity w języku java > com.example.cameraxapp > MainActivity.kt:
Na początku pliku, poza definicją klasy MainActivity, możesz dodać te stałe i importowane:
// Your IDE likely can auto-import these classes, but there are several
// different implementations so we list them here to disambiguate
import android.Manifest
import android.util.Size
import android.graphics.Matrix
import java.util.concurrent.TimeUnit
// This is an arbitrary number we are using to keep tab of the permission
// request. Where an app has multiple context for requesting permission,
// this can help differentiate the different contexts
private const val REQUEST_CODE_PERMISSIONS = 10
// This is an array of all the permission specified in the manifest
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
W ramach klasy MainActivity dodaj następujące pola i metody pomocnicze, które służą do zgłaszania próśb o uprawnienia i uruchamiania naszego kodu, gdy wiemy, że wszystkie uprawnienia zostały przyznane:
class MainActivity : AppCompatActivity(), LifecycleOwner {
override fun onCreate(savedInstanceState: Bundle?) {
...
}
// Add this after onCreate
private lateinit var viewFinder: TextureView
private fun startCamera() {
// TODO: Implement CameraX operations
}
private fun updateTransform() {
// TODO: Implement camera viewfinder transformations
}
/**
* Process result from permission request dialog box, has the request
* been granted? If yes, start Camera. Otherwise display a toast
*/
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
viewFinder.post { startCamera() }
} else {
Toast.makeText(this,
"Permissions not granted by the user.",
Toast.LENGTH_SHORT).show()
finish()
}
}
}
/**
* Check if all permission specified in the manifest have been granted
*/
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(
baseContext, it) == PackageManager.PERMISSION_GRANTED
}
}
Na koniec streściliśmy wszystko w elemencie onCreate, aby w razie potrzeby wywołać żądanie uprawnień:
override fun onCreate(savedInstanceState: Bundle?) {
...
// Add this at the end of onCreate function
viewFinder = findViewById(R.id.view_finder)
// Request camera permissions
if (allPermissionsGranted()) {
viewFinder.post { startCamera() }
} else {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
}
// Every time the provided texture view changes, recompute layout
viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
updateTransform()
}
}
Teraz po uruchomieniu aplikacji sprawdzi, czy ma ona odpowiednie uprawnienia do aparatu. W takim przypadku wywoła polecenie „startCamera()”. W przeciwnym razie poprosi o uprawnienia, a gdy już je uzyskasz, wywoła polecenie „startCamera()”.
W przypadku większości aplikacji użycie kamery jest bardzo ważne. Jeśli tego nie zrobisz, użytkownicy będą musieli skierować aparat w odpowiednie miejsce. Wizjer można zaimplementować za pomocą klasy „Podgląd” aparatu.
Aby używać wersji przedpremierowej, musimy najpierw zdefiniować konfigurację, która zostanie użyta do utworzenia instancji przypadku użycia. Powstała w ten sposób instancja musi być powiązana z cyklem życia aplikacji CameraX. Będziemy to robić za pomocą metody `startCamera()`. Wypełnij implementację, używając tego kodu:
private fun startCamera() {
// Create configuration object for the viewfinder use case
val previewConfig = PreviewConfig.Builder().apply {
setTargetAspectRatio(Rational(1, 1))
setTargetResolution(Size(640, 640))
}.build()
// Build the viewfinder use case
val preview = Preview(previewConfig)
// Every time the viewfinder is updated, recompute layout
preview.setOnPreviewOutputUpdateListener {
// To update the SurfaceTexture, we have to remove it and re-add it
val parent = viewFinder.parent as ViewGroup
parent.removeView(viewFinder)
parent.addView(viewFinder, 0)
viewFinder.surfaceTexture = it.surfaceTexture
updateTransform()
}
// Bind use cases to lifecycle
// If Android Studio complains about "this" being not a LifecycleOwner
// try rebuilding the project or updating the appcompat dependency to
// version 1.1.0 or higher.
CameraX.bindToLifecycle(this, preview)
}
Na tym etapie musimy wdrożyć nową tajemniczą metodę `updateTransform()`. Celem funkcji „updateTransform()” jest zrekompensowanie zmian w orientacji urządzenia, które wyświetlają nasz wizjer w orientacji pionowej:
private fun updateTransform() {
val matrix = Matrix()
// Compute the center of the view finder
val centerX = viewFinder.width / 2f
val centerY = viewFinder.height / 2f
// Correct preview output to account for display rotation
val rotationDegrees = when(viewFinder.display.rotation) {
Surface.ROTATION_0 -> 0
Surface.ROTATION_90 -> 90
Surface.ROTATION_180 -> 180
Surface.ROTATION_270 -> 270
else -> return
}
matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)
// Finally, apply transformations to our TextureView
viewFinder.setTransform(matrix)
}
Aby wdrożyć aplikację produkcyjną, zapoznaj się z oficjalną próbką, by dowiedzieć się, co trzeba jeszcze zrobić. Aby przyspieszyć pracę z ćwiczeniami z programowania, przygotowaliśmy kilka skrótów. Na przykład nie śledzimy pewnych zmian w konfiguracji, takich jak obracanie urządzenia o 180 stopni, które nie aktywują detektora zmian układu. Wizualizatory inne niż kwadratowe muszą zrekompensować zmianę proporcji podczas obracania urządzenia.
Jeśli skompilujemy i uruchomimy aplikację, wyświetli się podgląd jej użytkowania. Super!
Aby umożliwić użytkownikom przechwytywanie obrazów, w układzie po wyświetleniu tekstury w obszarze > układu > activity_main.xml pojawi się przycisk:
<ImageButton
android:id="@+id/capture_button"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_margin="24dp"
app:srcCompat="@android:drawable/ic_menu_camera"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
Inne przypadki użycia działają bardzo podobnie jak w przypadku podglądu. Najpierw należy zdefiniować obiekt konfiguracji, który służy do tworzenia instancji konkretnego przypadku użycia. Aby robić zdjęcia, po naciśnięciu przycisku przechwytywania ekranu należy zaktualizować metodę „startCamera()” i dodać jeszcze kilka wierszy kodu na końcu, przed wywołaniem Aparatu X.bindToLifecycle:
private fun startCamera() {
...
// Add this before CameraX.bindToLifecycle
// Create configuration object for the image capture use case
val imageCaptureConfig = ImageCaptureConfig.Builder()
.apply {
setTargetAspectRatio(Rational(1, 1))
// We don't set a resolution for image capture; instead, we
// select a capture mode which will infer the appropriate
// resolution based on aspect ration and requested mode
setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
}.build()
// Build the image capture use case and attach button click listener
val imageCapture = ImageCapture(imageCaptureConfig)
findViewById<ImageButton>(R.id.capture_button).setOnClickListener {
val file = File(externalMediaDirs.first(),
"${System.currentTimeMillis()}.jpg")
imageCapture.takePicture(file,
object : ImageCapture.OnImageSavedListener {
override fun onError(error: ImageCapture.UseCaseError,
message: String, exc: Throwable?) {
val msg = "Photo capture failed: $message"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
Log.e("CameraXApp", msg)
exc?.printStackTrace()
}
override fun onImageSaved(file: File) {
val msg = "Photo capture succeeded: ${file.absolutePath}"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
Log.d("CameraXApp", msg)
}
})
}
// Bind use cases to lifecycle
// If Android Studio complains about "this" being not a LifecycleOwner
// try rebuilding the project or updating the appcompat dependency to
// version 1.1.0 or higher.
CameraX.bindToLifecycle(this, preview)
}
Następnie zaktualizuj wywołanie aplikacji CameraX.bindToLifecycle, by uwzględnić nowy przypadek użycia:
CameraX.bindToLifecycle(this, preview, imageCapture)
Wprowadziliśmy też funkcjonalny przycisk do robienia zdjęć.
Bardzo interesującą funkcją Aparatu X jest klasa ImageAnalysis. Umożliwia on określenie klasy niestandardowej implementującej interfejs ImageAnalysis.Analizer, który będzie wywoływany z przychodzącymi klatkami kamery. Zgodnie z podstawową wizją Aparatu X nie musimy się martwić o zarządzanie stanem sesji aparatu ani nawet utylizowaniem zdjęć. Powiązanie z aplikacją „Twój cykl życia” jest wystarczającym czynnikiem jak inne komponenty zależne od cyklu życia.
Najpierw wdrożymy niestandardowy analizator obrazu. Analizator jest dosyć prosty – rejestruje tylko średnią luminację (jasność) obrazu i pokazuje, co trzeba zrobić w dowolnych skomplikowanych przypadkach. Wystarczy, że zastąpisz funkcję „Analiza” w klasie, która ma interfejs ImageAnalysis.Analizer. Implementację można definiować jako klasę wewnętrzną w MainActivity:
private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
private var lastAnalyzedTimestamp = 0L
/**
* Helper extension function used to extract a byte array from an
* image plane buffer
*/
private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // Rewind the buffer to zero
val data = ByteArray(remaining())
get(data) // Copy the buffer into a byte array
return data // Return the byte array
}
override fun analyze(image: ImageProxy, rotationDegrees: Int) {
val currentTimestamp = System.currentTimeMillis()
// Calculate the average luma no more often than every second
if (currentTimestamp - lastAnalyzedTimestamp >=
TimeUnit.SECONDS.toMillis(1)) {
// Since format in ImageAnalysis is YUV, image.planes[0]
// contains the Y (luminance) plane
val buffer = image.planes[0].buffer
// Extract image data from callback object
val data = buffer.toByteArray()
// Convert the data into an array of pixel values
val pixels = data.map { it.toInt() and 0xFF }
// Compute average luminance for the image
val luma = pixels.average()
// Log the new luma value
Log.d("CameraXApp", "Average luminosity: $luma")
// Update timestamp of last analyzed frame
lastAnalyzedTimestamp = currentTimestamp
}
}
}
Wystarczy, że nasze zajęcia wdrożymy interfejs ImageAnalysis.Analizer. Wystarczy, że utworzysz wystąpienie ImageAnalysis w taki sposób, jak w przypadku innych przypadków użycia, i jeszcze raz zaktualizujesz funkcję `startCamera()`, zanim wywołasz aplikację CameraX.bindToLifecycle:
private fun startCamera() {
...
// Add this before CameraX.bindToLifecycle
// Setup image analysis pipeline that computes average pixel luminance
val analyzerConfig = ImageAnalysisConfig.Builder().apply {
// Use a worker thread for image analysis to prevent glitches
val analyzerThread = HandlerThread(
"LuminosityAnalysis").apply { start() }
setCallbackHandler(Handler(analyzerThread.looper))
// In our analysis, we care more about the latest image than
// analyzing *every* image
setImageReaderMode(
ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
}.build()
// Build the image analysis use case and instantiate our analyzer
val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
analyzer = LuminosityAnalyzer()
}
// Bind use cases to lifecycle
// If Android Studio complains about "this" being not a LifecycleOwner
// try rebuilding the project or updating the appcompat dependency to
// version 1.1.0 or higher.
CameraX.bindToLifecycle(this, preview, imageCapture)
}
Aby powiązać nowy przypadek użycia, aktualizujemy wywołanie aplikacji CameraX.bindtoLifecycle:
CameraX.bindToLifecycle(
this, preview, imageCapture, analyzerUseCase)
Uruchomienie aplikacji spowoduje teraz wygenerowanie podobnego komunikatu w logcat w mniej więcej jednej sekundzie:
D/CameraXApp: Average luminosity: ...
Aby przetestować aplikację, wystarczy kliknąć przycisk Uruchom w Android Studio, a nasz projekt zostanie utworzony, wdrożony i uruchomiony na wybranym urządzeniu lub w emulatorze. Po załadowaniu aplikacji powinien być widoczny wizjer, który pozostaje pionowy, nawet po obróceniu urządzenia (za pomocą dodanego wcześniej kodu orientacji) i możliwość robienia zdjęć za pomocą przycisku:
Udało Ci się ukończyć moduł kodu. W przyszłości zaimplementowaliśmy w nowej aplikacji na Androida te elementy od zera:
- Uwzględnione w projekcie zależności z XX.
- Wyświetlono wizjer aparatu (przypadek użycia podglądu)
- Wdrożono robienie zdjęć i zapisuję obrazy w pamięci (przy użyciu przypadku użycia aplikacji ImageCapture).
- Wdrożono analizę klatek z kamery w czasie rzeczywistym (przypadek użycia ImageAnalysis)
Jeśli chcesz dowiedzieć się więcej o Aparacie X i o tym, co możesz z nim zrobić, przeczytaj dokumentację lub skopiuj oficjalną próbkę.