في هذا الدرس العملي، سنتعلّم كيفية إنشاء تطبيق كاميرا يستخدم CameraX لعرض إطار العرض والتقاط الصور وتحليل بث الصور من الكاميرا.
ولتحقيق ذلك، سنقدّم مفهوم حالات الاستخدام في CameraX، والذي يمكن استخدامه في مجموعة متنوعة من عمليات الكاميرا، بدءًا من عرض عدسة الكاميرا إلى تحليل اللقطات في الوقت الفعلي.
ما سنتعلّمه
- كيفية إضافة تبعيات CameraX
- كيفية عرض معاينة الكاميرا في نشاط (حالة الاستخدام في المعاينة)
- كيفية التقاط صورة وحفظها في مساحة التخزين (حالة استخدام ImageCapture)
- كيفية تحليل اللقطات من الكاميرا في الوقت الفعلي (حالة استخدام ImageAnalysis)
الأجهزة التي سنحتاج إليها
- جهاز Android، علمًا بأنّ محاكي Android Studio سيفي بالغرض. الحد الأدنى لمستوى واجهة برمجة التطبيقات المتوافق هو 21.
البرامج التي سنحتاج إليها
- الإصدار 3.3 من "استوديو Android" أو إصدار أحدث
باستخدام قائمة Android Studio، ابدأ مشروعًا جديدًا واختَر Empty Activity عند مطالبتك بذلك.
بعد ذلك، يمكننا اختيار أي اسم نريده، وقد اخترنا بذكاء "تطبيق CameraX". يجب التأكّد من ضبط اللغة على Kotlin، وأنّ الحد الأدنى لمستوى واجهة برمجة التطبيقات هو 21 (وهو الحد الأدنى المطلوب لاستخدام CameraX)، وأنّنا نستخدم عناصر AndroidX.
للبدء، لنضِف ملحقات CameraX إلى ملف Gradle الخاص بتطبيقنا، داخل قسم dependencies :
// 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}"
عندما يُطلب منك ذلك، انقر على المزامنة الآن، وسنجهّز CameraX للاستخدام في تطبيقنا.
سنستخدم SurfaceTexture لعرض شاشة عرض الكاميرا. في هذا الدرس العملي، سنعرض عدسة الكاميرا بتنسيق مربّع وبحجم ثابت. للحصول على مثال أكثر شمولاً يعرض شاشة عرض متجاوبة، يمكنك الاطّلاع على العينة الرسمية.
لنعدّل ملف تنسيق activity_main ضِمن res > layout > 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>
يُعدّ طلب أذونات CAMERA المناسبة جزءًا مهمًا من إضافة أي وظيفة في مشروعنا تستخدم الكاميرا. أولاً، يجب تعريفها في ملف البيان قبل علامة Application:
<uses-permission android:name="android.permission.CAMERA" />
بعد ذلك، داخل MainActivity، علينا طلب الأذونات في وقت التشغيل. سنُجري التغييرات في ملف MainActivity ضِمن java > com.example.cameraxapp > MainActivity.kt:
في أعلى الملف، خارج تعريف فئة MainActivity، لنضِف الثوابت وعمليات الاستيراد التالية:
// 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)
داخل فئة MainActivity، أضِف الحقول وطُرق المساعدة التالية التي تُستخدَم لطلب الأذونات وتشغيل الرمز البرمجي بعد التأكّد من منح جميع الأذونات:
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
}
}
أخيرًا، نضع كل شيء معًا داخل onCreate لتفعيل طلب الإذن عند الحاجة:
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()
}
}
الآن، عند بدء تشغيل التطبيق، سيتحقّق مما إذا كان لديه أذونات الكاميرا المناسبة. وفي حال توفُّرها، سيتم استدعاء الدالة <code>startCamera()</code> مباشرةً. وفي الحالات الأخرى، سيطلب الأذونات، وبعد منحها، سيتم استدعاء الدالة `startCamera()`.
في معظم تطبيقات الكاميرا، من المهم جدًا عرض عدسة الكاميرا للمستخدمين، وإلا سيكون من الصعب جدًا عليهم توجيه الكاميرا إلى المكان الصحيح. يمكن تنفيذ عدسة الكاميرا باستخدام فئة `Preview` في CameraX.
لاستخدام "المعاينة"، علينا أولاً تحديد إعدادات يتم استخدامها بعد ذلك لإنشاء مثيل لحالة الاستخدام. وهذه النسخة الناتجة هي ما نحتاج إلى ربطه بدورة حياة CameraX. سننفّذ ذلك ضمن طريقة `startCamera()`، لذا املأ التنفيذ بهذا الرمز:
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)
}
في هذه المرحلة، علينا تنفيذ طريقة `updateTransform()` الغامضة. داخل الدالة `updateTransform()`، الهدف هو تعويض التغييرات في اتجاه الجهاز لعرض عدسة الكاميرا في وضع عمودي:
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)
}
لتنفيذ تطبيق جاهز للإصدار العلني، ألقِ نظرة على العينة الرسمية لمعرفة ما يجب التعامل معه أيضًا. لإبقاء هذا الدرس البرمجي قصيرًا، سنستخدم بعض الاختصارات. على سبيل المثال، لا نتتبّع بعض تغييرات الإعدادات، مثل تدوير الجهاز بمقدار 180 درجة، والتي لا تؤدي إلى تشغيل أداة معالجة تغييرات التصميم. يجب أيضًا أن تعوّض عدسات تحديد المنظر غير المربّعة عن تغيير نسبة العرض إلى الارتفاع عند تدوير الجهاز.
إذا أنشأنا التطبيق وشغّلناه، من المفترض أن تظهر الآن معاينة مباشرة. أحسنت.
للسماح للمستخدمين بالتقاط الصور، سنوفّر زرًا كجزء من التصميم بعد عرض الملمس في res > layout > activity_main.xml:
<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" />
تعمل حالات الاستخدام الأخرى بطريقة مشابهة جدًا لطريقة عمل "المعاينة". أولاً، يجب تحديد عنصر إعدادات يُستخدَم لإنشاء عنصر حالة الاستخدام الفعلي. لالتقاط الصور، عند الضغط على زر الالتقاط، علينا تعديل طريقة `startCamera()` وإضافة بضعة أسطر أخرى من الرمز في النهاية، قبل استدعاء CameraX.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)
}
بعد ذلك، عدِّل استدعاء CameraX.bindToLifecycle لتضمين حالة الاستخدام الجديدة:
CameraX.bindToLifecycle(this, preview, imageCapture)
بهذه الخطوات، نكون قد نفّذنا زرًا وظيفيًا لالتقاط الصور.
من الميزات المثيرة للاهتمام في CameraX فئة ImageAnalysis. يسمح لنا بتحديد فئة مخصّصة تنفّذ واجهة ImageAnalysis.Analyzer، وسيتم استدعاؤها باستخدام إطارات الكاميرا الواردة. تماشيًا مع الرؤية الأساسية لمكتبة CameraX، لن نقلق بشأن إدارة حالة جلسة الكاميرا أو حتى التخلص من الصور، إذ يكفي الربط بدورة الحياة المطلوبة لتطبيقنا مثل المكوّنات الأخرى التي تراعي دورة الحياة.
أولاً، سننفّذ أداة تحليل صور مخصّصة. المحلّل بسيط جدًا، فهو يسجّل متوسط درجة الإضاءة (السطوع) للصورة، ولكنّه يوضّح ما يجب فعله لحالات الاستخدام المعقّدة بشكلٍ عشوائي. كل ما علينا فعله هو إلغاء الدالة `analyze` في فئة تنفّذ واجهة ImageAnalysis.Analyzer. يمكننا تحديد عملية التنفيذ كفئة داخلية ضمن 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
}
}
}
بعد أن نفّذنا الفئة لواجهة ImageAnalysis.Analyzer، كل ما علينا فعله هو إنشاء مثيل لـ ImageAnalysis كما هو الحال مع جميع حالات الاستخدام الأخرى وتعديل الدالة `startCamera()` مرة أخرى، قبل استدعاء 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)
}
ونعدّل أيضًا استدعاء CameraX.bindtoLifecycle لربط حالة الاستخدام الجديدة:
CameraX.bindToLifecycle(
this, preview, imageCapture, analyzerUseCase)
سيؤدي تشغيل التطبيق الآن إلى ظهور رسالة مشابهة لهذه الرسالة في logcat كل ثانية تقريبًا:
D/CameraXApp: Average luminosity: ...
لاختبار التطبيق، ما علينا سوى النقر على الزر تشغيل في "استوديو Android" وسيتم إنشاء مشروعنا ونشره وتشغيله على الجهاز أو المحاكي المحدّد. بعد تحميل التطبيق، من المفترض أن نرى عدسة الكاميرا التي ستظل في وضع مستقيم حتى بعد تدوير الجهاز بفضل الرمز البرمجي الذي أضفناه سابقًا للتعامل مع الاتجاه، ومن المفترض أن نتمكّن أيضًا من التقاط الصور باستخدام الزر:
لقد أكملت الدرس التطبيقي بنجاح. عند الرجوع إلى الماضي، نفّذت ما يلي في تطبيق Android جديد من البداية:
- تضمين تبعيات CameraX في مشروعك
- عرض عدسة الكاميرا (باستخدام حالة الاستخدام "معاينة")
- تم تنفيذ عملية التقاط الصور وحفظها في مساحة التخزين (باستخدام حالة استخدام ImageCapture)
- تم تنفيذ تحليل للّقطات من الكاميرا في الوقت الفعلي (باستخدام حالة استخدام ImageAnalysis)
إذا كنت مهتمًا بمعرفة المزيد عن CameraX والأشياء التي يمكنك تنفيذها باستخدامها، يمكنك الاطّلاع على المستندات أو استنساخ العينة الرسمية.