บทความวันนี้ขอพูดถึงเรื่องวิธีการเรียกใช้งาน Camera API บนอุปกรณ์แอนดรอยด์กันบ้าง ถึงแม้ว่า Android จะเปิดให้แอปฯต่างๆสามารถเรียกใช้งานกล้องจากแอปฯตัวอื่นๆที่มีอยู่ในเครื่องได้ แต่ในบางครั้งก็อาจจะมีความจำเป็นที่จะต้องเขียนเองบ้าง เพื่อ Custom การทำงานของกล้องให้เหมาะสมกับงานของผู้ที่หลงเข้ามาอ่าน

บทความที่เกี่ยวข้อง

Camera API v1 was deprecated

ก่อนจะเรียกใช้งาน Camera API v1 ก็อยากจะให้รู้กันก่อนว่า เมื่อ Android 5.0 Lollipop เปิดตัวขึ้นมา ก็ได้มีการเพิ่ม Camera API v2 เข้ามาด้วยเช่นกัน เพื่อเพิ่มความสามารถในการเรียกใช้งานกล้องให้ยืดหยุ่นมากขึ้น ทำอะไรได้มากขึ้น และก็ได้ประกาศ Deprecated ให้กับ Camera API v1 ด้วยเช่นกัน

แต่ทว่าในปัจจุบันนี้ (ที่เจ้าของบล็อกเขียนบทความนี้) ยังมีผู้ใช้อุปกรณ์แอนดรอยด์เป็นเวอร์ชันต่ำกว่า Android 5.0 Lollipop อยู่มากมาย จึงทำให้ไม่สามารถย้ายไปใช้ Camera API v2 ได้อย่างเต็มที่ จึงเป็นที่มาว่าทำไม Camera API v1 จึงยังจำเป็นอยู่ ซึ่งขึ้นอยู่กับว่าจะเลือกเขียนแบบไหน ระหว่าง

  • เรียกใช้ Camera API v1 เพื่อรองรับทั้งเวอร์ชันเก่าและใหม่
  • เรียกใช้ Camera API v1 สำหรับเวอร์ชันที่ต่ำกว่า API 21 และเรียกใช้ Camera API v2 สำหรับ API 21 ขึ้นไป

แต่เมื่อถึงเวลาที่นักพัฒนาสามารถกำหนด Minimum SDK เป็น API 21 ได้ ก็แนะนำให้เลิกใช้ Camera API v1 แล้วเปลี่ยนไปใช้ Camera API v2 แทนนะครับ

เริ่มต้นเรียกใช้งาน Camera API v1

ก่อนอื่นต้องเข้าใจก่อนว่า การเรียกใช้งาน Camera API v1 นั้นจะมีองค์ประกอบอยู่หลายๆส่วนด้วยกัน โดยจะแบ่งเป็นสองส่วนดังรูปข้างล่างนี้

Camera

เป็นส่วนที่เรียกใช้งานกล้องภายในเครื่อง รวมไปถึงการกำหนดค่าต่างๆและการทำงานของกล้อง ไม่ว่าจะเป็น การเลือกใช้งานกล้องหน้าหรือกล้องหลัง, ตั้งค่า White Balance, กำหนดขนาดภาพถ่ายหรือวีดีโอ, สั่งให้ถ่ายภาพ และสั่งให้บันทึกวีดีโอ

Preview

เป็นส่วนที่ใช้ในการ Preview ภาพเมื่อเรียกใช้งานกล้อง ซึ่งเดิมทีนั้น Camera ไม่ได้จัดการเรื่องนี้เอง แต่จะมีคำสั่งสำหรับกำหนดว่าจะให้แสดง Preview จากหน้ากล้องที่ใด ซึ่งในบทความนี้จะใช้ TextureView ในการแสดง Preview ซึ่งผู้ที่หลงเข้ามาอ่านสามารถใช้ SurfaceView ก็ได้เช่นกัน แต่ว่า TextureView มีความสะดวกและยืดหยุ่นในการเรียกใช้งานมากกว่า (แต่ก็ต้องเป็น API 16 ขึ้นไปเช่นกัน)

ซึ่งโค้ดของ Camera และ Preview จะทำงานอยู่ร่วมกันเสมอ เพราะต้องมีการจัดการกับทั้งสองส่วนเพื่อให้ทำงานตาม Lifecycle ของ Activity/Fragment ได้อย่างถูกต้อง

รูปแบบการเรียกใช้งาน Camera และ Preview ในแบบฉบับของเจ้าของบล็อก

ปัญหาหลักๆของนักพัฒนาส่วนใหญ่ที่ต้องเรียกใช้งาน Camera API v1 คือ จัดการกับคำสั่งของ Camera และ Preview ไม่ถูกต้อง เพราะจะต้องทำให้ Camera และ Preview ทำงานตาม Lifecycle ของ Activity/Fragment ด้วย โดยจะต้องรองรับการใช้งานในรูปแบบต่างๆดังนี้

  • เปิดใช้งานกล้อง แล้วปิดใช้งานกล้องด้วยการทำลาย Activity หรือ Fragment ทิ้ง
  • เปิดใช้งานกล้อง แล้วหมุนหน้าจอไปมา
  • เปิดใช้งานกล้อง แล้วเปิด Activity หรือ Fragment ตัวอื่นขึ้นมาแล้วกลับไปเปิดหน้าเดิมอีกครั้ง
  • เปิดใช้งานกล้อง แล้วสลับไปใช้งานกล้องตัวอื่น แล้วกลับมาเปิดแอปฯต่อจากเดิมอีกครั้ง
  • เปิดใช้งานกล้องบนแอปฯที่แสดงผลแบบ MultiWindow อยู่ แล้วย่อ/ขยายหน้าต่างไปมา

ซึ่งทั้ง 5 ข้อนี้เป็นรูปแบบการทำงานของแอปฯโดยพื้นฐานที่สามารถเกิดขึ้นได้ ดังนั้นเวลาเรียกใช้งาน Camera API ก็จะต้องทดสอบด้วยการทำงานทั้ง 5 แบบนี้เป็นอย่างน้อย เพื่อให้มั่นใจว่าผู้ใช้จะไม่เจอแอปฯเด้งระหว่างใช้งาน

ภาพข้างบนนี้คือลำดับการทำงานของคำสั่งต่างๆของ Camera และ Preview ที่สามารถรองรับรูปแบบการเรียกใช้งานทั้ง 5 แบบได้ อาจจะดูแตกต่างไปจากบทความหรือตัวอย่างโค้ดจากที่อื่นๆเล็กน้อย เพราะเจ้าของบล็อกกำหนดลำดับการทำงานนี้ขึ้นมาเอง

จากภาพดังกล่าวสามารถเขียนเป็นโค้ดแบบคร่าวๆได้ประมาณนี้

override fun onCreate(savedInstanceState: Bundle?) {
    // Setup Camera Preview and Listener
}

override fun onStart() {
    // Setup Camera
    // Start Camera Preview
}

override fun onStop() {
    // Stop Camera and Camera Preview
}

override fun onDestroy() {
    // Clear Listener
}

override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
    // Setup Camera
    // Start Camera Preview
}

override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
    // Stop Camera and Camera Preview
}

สำหรับ onSurfaceTextureAvailable(...) กับ onSurfaceTextureDestroyed(...) มาจาก SurfaceTextureListener ของ TextureView ซึ่งจะพูดถึงในภายหลัง

เตรียม TextureView ให้พร้อม

TextureView เป็น View ที่มีอยู่ใน API 16 ขึ้นไป สามารถเรียกใช้งานใน Layout XML ได้เหมือนกับ View ทั่วๆไป

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextureView
        android:id="@+id/textureViewCamera"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</FrameLayout>

แล้วเตรียมไว้ใน Activity ให้พร้อมซะ

import android.graphics.SurfaceTexture
import android.os.Bundle
import android.view.TextureView
import androidx.appcompat.app.AppCompatActivity

class CameraV1Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */
        textureViewCamera.surfaceTextureListener = surfaceTextureListener
    }
    /* ... */
    private val surfaceTextureListener = object : TextureView.SurfaceTextureListener {
        override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {

        }

        override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {

        }

        override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
            return false
        }

        override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {

        }
    }
}

จะเห็นว่า TextureView ต้องมีการกำหนด SurfaceTextureListener ด้วย ซึ่งตอนนี้จะเป็นแค่การเตรียมคำสั่งไว้ให้พร้อมก่อน

เรียกใช้งาน Camera API v1

กำหนด Uses Permission และ Uses Feature

ในการเรียกใช้งาน Camera API นั้นจะต้องมีการประกาศ Permission ใน Android Manifest ด้วยทุกครั้ง

<!-- AndroidManifest.xml -->
<manifest>
    <uses-permission android:name="android.permission.CAMERA" />
    /* ... */
</manifest>

และถ้าต้องการให้ติดตั้งได้เฉพาะบนเครื่องที่มีกล้องเท่านั้น (บน Google Play) ให้กำหนดเพิ่มเข้าไปดังนี้

<!-- AndroidManifest.xml -->
<manifest>
    <uses-feature
        android:name="android.hardware.camera"
        android:required="true" />

    <uses-feature
        android:name="android.hardware.camera.autofocus"
        android:required="false" />
    /* ... */
</manifest>

จะเห็นว่าเจ้าของบล็อกกำหนด Autofocus เป็น False เพราะว่าเคยเจออุปกรณ์แอนดรอยด์บางรุ่นที่รองรับ Autofocus แต่ทว่าตัว Firmware กำหนดค่าไว้ผิด ทำให้เช็คผ่านโค้ดแล้วจะกลายเป็นไม่รองรับ Autofocus จึงทำให้ไม่สามารถดาวน์โหลดแอปฯได้ ทั้งๆที่ตัวเครื่องมีกล้องและรองรับ Autofocus

ซึ่งในการเรียกใช้งานกล้องโดยพื้นฐานนั้น เครื่องจะรองรับ Autofocus หรือไม่นั้นไม่ได้ส่งผลอะไรกับการทำงานของแอปฯซักเท่าไร ดังนั้นเจ้าของบล็อกจึงแนะนำให้กำหนด Autofocus เป็น False ไว้ด้วยทุกครั้ง

อย่าลืม Runtime Permission สำหรับ Android 6.0 Marshmallow ขึ้นไปด้วย

ถ้าเป็นไปได้ แนะนำให้เรียกคำสั่งของ Runtime Permission ใน Activity ก่อนที่จะเปิด Activity ที่เรียกใช้งานกล้อง เพราะคำสั่ง Runtime Permission อาจจะทำให้ลำดับการทำงานของคำสั่งใน Camera API v1 ชวนสับสนและผิดได้ง่าย

import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.appcompat.app.AppCompatActivity

class SplashScreenActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */
        checkCameraPermission();
    }

    private fun checkCameraPermission() {
        if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE);
        }
    }

    override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // Camera permission granted
            } else {
                // Camera permission denied
            }
        }
    }
    /* ... */
}

หรือจะใช้ Library อย่าง Dexter มาช่วยจัดการเพื่อให้โค้ดกระชับมากกว่านี้ก็ได้เช่นกัน

การเช็คผ่านโค้ดว่าอุปกรณ์นั้นๆมีกล้องให้ใช้งานหรือไม่

ในกรณีที่อยากจะเช็คผ่านโค้ดว่าเครื่องนั้นๆมีกล้องให้ใช้งานหรือไม่ สามารถใช้คำสั่งดังนี้ได้เลย

fun isCameraSupport(context: Context): Boolean {
    return context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
}

เปิดใช้งานกล้อง

มีคำสั่งอยู่ 2 แบบ แบบแรกคือเรียกใช้งาน Default Camera ของตัวเครื่อง กับระบุเองว่าจะเอากล้องตัวไหน

import android.hardware.Camera
/* ... */
val camera: Camera = Camera.open()

// หรือกำหนดกล้องที่ต้องการด้วย
// Camera.CameraInfo.CAMERA_FACING_BACK
// Camera.CameraInfo.CAMERA_FACING_FRONT
val camera: Camera = Camera.open(Camera.CameraInfo.CAMERA_FACING_BACK)

และสามารถเช็คได้ว่าเครื่องนั้นๆมีกล้องกี่ตัวด้วยคำสั่งดังนี้

val cameraCount: Int = Camera.getNumberOfCameras()

แต่คำสั่ง Camera.open() และ Camera.open(cameraId) มีโอกาสเกิด RuntimeException ได้ ดังนั้นเพื่อให้ยืดหยุ่นต่อการเรียกใช้งาน เจ้าของบล็อกจึงทำเป็นคำสั่งแบบนี้

fun openDefaultCamera(): Camera? {
    try {
        return Camera.open()
    } catch (e: RuntimeException) {
        Log.e(TAG, "Error open camera: " + e.message)
    }
    return null
}

fun openCamera(cameraId: Int): Camera? {
    try {
        return Camera.open(cameraId)
    } catch (e: RuntimeException) {
        Log.e(TAG, "Error open camera: " + e.message)
    }
    return null
}

ถ้าเปิดใช้งานกล้องไม่ได้ก็จะได้ค่าเป็น Null แทน

กำหนดค่าต่างๆให้กับกล้อง

Camera Instance ที่ได้จากคำสั่ง Camera.open() หรือ Camera.open(cameraId) สามารถเช็คได้ว่ากล้องดังกล่าวรองรับการตั้งค่าอะไรบ้าง เพื่อใช้ในการกำหนดค่าเริ่มต้นให้กับกล้อง ด้วยคำสั่ง

val camera: Camera? = openCamera(cameraId)
camera?.let { camera: Camera ->
    val parameters: Camera.Parameters = camera.parameters
}

แล้วก็จะเห็นคำสั่งต่างๆที่อยู่ในคลาสดังกล่าว และสามารถเช็คได้ว่ากล้องรองรับอะไรได้บ้าง

สำหรับการกำหนดค่าลงใน Parameter สามารถกำหนดผ่าน Setter ได้เลย แล้วให้กำหนดค่า Parameter กลับเข้าไปใน Camera ด้วยทุกครั้ง

val camera: Camera? = openCamera(cameraId)
camera?.let { camera: Camera ->
    val parameters = camera.parameters
    parameters.jpegQuality = 100
    parameters.jpegThumbnailQuality = 50
    parameters.videoStabilization = true
    camera.parameters = parameters   
}

การกำหนดขนาดของภาพถ่าย

เนื่องจากกล้องของอุปกรณ์แอนดรอยด์แต่ละเครื่องนั้นมีขนาดแตกต่างกันไป ซึ่งสามารถเช็คได้จาก Parameters ของกล้องนั้นๆ

val camera: Camera? = openCamera(cameraId)
camera?.let { camera: Camera ->
    val parameters = camera.parameters
    val supportedPictureSizeList: List<Camera.Size> = parameters.supportedPictureSizes   
}

อย่างที่บอกไปว่าแต่ละเครื่องจะรองรับขนาดภาพถ่ายแตกต่างกันออกไป ดังนั้นจึงสามารถเขียนโค้ดให้ผู้ใช้เลือกได้ว่าจะให้ถ่ายภาพที่ความละเอียดเท่าไร

แต่ในตัวอย่างนี้เจ้าของบล็อกจะใช้วิธีดึงค่าขนาดของภาพภ่ายที่มากที่สุดมากำหนดให้กับกล้อง จึงต้องเขียน Method เพิ่มขึ้นมาดังนี้

fun getBestPictureSize(
    supportedSizeList: List<Camera.Size>?,
    width: Int = Int.MAX_VALUE,
    height: Int = Int.MAX_VALUE
): Camera.Size? {
    if (supportedSizeList == null || supportedSizeList.isEmpty()) {
        return null
    }
    var bestSize: Camera.Size? = null
    val requiredLong = if (width > height) width else height
    val requiredShort = if (width > height) height else width
    supportedSizeList.forEach { supportedSize ->
        val compareLong = if (supportedSize.width > supportedSize.height) supportedSize.width else supportedSize.height
        val compareShort = if (supportedSize.width > supportedSize.height) supportedSize.height else supportedSize.width
        val bestLong = if (bestSize?.width ?: 0 > bestSize?.height ?: 0) bestSize?.width ?: 0 else bestSize?.height ?: 0
        val bestShort = if (bestSize?.width ?: 0 > bestSize?.height ?: 0) bestSize?.height ?: 0 else bestSize?.width ?: 0
        if (compareLong <= requiredLong &&
            compareShort <= requiredShort &&
            compareLong >= bestLong &&
            compareShort >= bestShort
        ) {
            bestSize = supportedSize
        }
    }
    return bestSize
}

สาเหตุที่ต้องเขียนคำสั่งแบบนี้ก็เพราะว่าขนาดภาพถ่ายที่รองรับของแต่ละเครื่องอาจจะไม่เหมือนกัน บางเครื่องเก็บค่าขนาดภาพถ่ายสูงสุดไว้ที่ Array ตัวแรกสุด บางเครื่องก็เก็บไว้ที่ Array ตัวท้ายสุด

โดยจะเรียกคำสั่งดังกล่าวแล้วเอาค่าที่ได้ไปกำหนดให้กับขนาดของภาพถ่ายของกล้องดังนี้

val camera: Camera? = openCamera(cameraId)
camera?.let { camera: Camera ->
    val parameters = camera.parameters
    getBestPictureSize(parameters.supportedPictureSizes)?.let { pictureSize: Camera.Size ->
        parameters.setPictureSize(pictureSize.width, pictureSize.height)
    }
}

เพียงเท่านี้ก็จะได้ภาพถ่ายเป็นภาพที่มีขนาดใหญ่ที่สุดที่เครื่องรองรับแล้ว

การกำหนด Preview Size ของกล้อง

นอกจากขนาดของภาพถ่ายแล้ว อุปกรณ์แอนดรอยด์ยังสามารถกำหนดให้ Preview ภาพจากกล้องที่แสดงบนหน้าจอมีความละเอียดเท่าไรก็ได้ โดยจะต้องเป็นค่าที่อยู่ใน Camera.Parameters เท่านั้น

Camera camera = openCamera(cameraId);
Camera.Parameters parameters = camera.getParameters();
List<Camera.Size> previewSizeList = parameters.getSupportedPreviewSizes();

โดยสเปคของกล้องนั้นไม่ได้สัมพันธ์กับขนาดหน้าจอของเครื่องโดยตรง จึงทำให้บ่อยครั้งความละเอียดในการ Preview นั้นไม่สัมพันธ์กับขนาดของ TextureView บนหน้าจอ ดังนั้นผู้ที่หลงเข้ามาอ่านจึงต้องคำนวณขนาดของ TextureView เพื่อกำหนดความละเอียดในการ Preview ที่เหมาะสม เพื่อไม่ให้แอปทำงานหนักเกินจำเป็น

สมมติว่าแอปแสดงภาพ Preview จากกล้องแบบเต็มหน้าจอ และ TextureView ที่จะแสดงมีขนาด 1200 x 600 ก็จะต้องคำนวณหาความละเอียดที่ใกล้เคียงที่สุด

1920x1080
1440x1080
1088x1088
1280x720    << เหมาะสำหรับการแสดงบนพื้นที่ 1200x600px
1056x704
1024x768
960x720
800x450
720x720
720x480
640x480
352x288
320x240
256x144
176x144

จากตัวอย่างข้างบนนี้ ถ้ากำหนดให้ Preview Size มีขนาดสูงสุดก็จะทำให้สิ้นเปลืองเกินจำเป็น เพราะสุดท้ายก็แสดงผลแค่บน 1200x600 อยู่ดี

และนั่นก็หมายความว่าผู้ที่หลงเข้ามาอ่านต้องเขียนคำสั่งเพื่อให้ได้ Preview Size ที่เหมาะสมที่สุดนั่นเอง ซึ่งจะได้คำสั่งออกมาประมาณนี้

private fun getBestPreviewSize(
    supportedSizeList: List<Camera.Size>?,
    width: Int,
    height: Int
): Camera.Size? {
    if (supportedSizeList == null || supportedSizeList.isEmpty()) {
        return null
    }
    var bestSize: Camera.Size? = null
    val requiredLong = if (width > height) width else height
    val requiredShort = if (width > height) height else width
    supportedSizeList.forEach { supportedSize ->
        val compareLong = if (supportedSize.width > supportedSize.height) supportedSize.width else supportedSize.height
        val compareShort = if (supportedSize.width > supportedSize.height) supportedSize.height else supportedSize.width
        val bestLong = if (bestSize?.width ?: Int.MAX_VALUE > bestSize?.height ?: Int.MAX_VALUE) bestSize?.width ?: Int.MAX_VALUE else bestSize?.height ?: Int.MAX_VALUE
        val bestShort = if (bestSize?.width ?: Int.MAX_VALUE > bestSize?.height ?: Int.MAX_VALUE) bestSize?.height ?: Int.MAX_VALUE else bestSize?.width ?: Int.MAX_VALUE
        if (compareLong >= requiredLong &&
            compareShort >= requiredShort &&
            compareLong <= bestLong &&
            compareShort <= bestShort
        ) {
            bestSize = supportedSize
        }
    }
    return bestSize
}

เพียงเท่านี้ก็จะได้ Preview Size ที่เหมาะสมที่สุดแล้ว

val width = 10
val height = 0

val camera: Camera? = openCamera(cameraId)
camera?.let { camera: Camera ->
    val parameters = camera.parameters
    getBestPreviewSize(parameters.supportedPreviewSizes, width, height)?.let { previewSize: Camera.Size ->
        parameters.setPreviewSize(previewSize.width, previewSize.height)
    }
}

เนื่องจาก Preview Size ขึ้นอยู่กับขนาดของ TextureView ที่จะนำไปแสดง ดังนั้นจะต้องมีการกำหนดความกว้างและความสูงของ TextureView เพื่อนำไปคำนวณด้วย ซึ่งจะต่างจากขนาดของภาพถ่ายที่สนใจแค่ความละเอียดที่สูงที่สุดเท่านั้น

การกำหนด Orientation ของภาพจากกล้อง

เนื่องจากทิศทางของภาพจากกล้องนั้นไม่ได้สัมพันธ์กับทิศทางของหน้าจอ หรือพูดง่ายๆก็คือภาพบนหน้าจออาจจะกลับหัวกลับหางได้นั่นเอง

ดังนั้นผู้ที่หลงเข้ามาอ่านจะต้องใช้คำสั่งเพื่อดึงค่า Orientation ของกล้องมาคำนวณเพื่อให้ภาพที่แสดงบนหน้าจอนั้นมีทิศทางที่ถูกต้อง

val cameraId = Camera.CameraInfo.CAMERA_FACING_FRONT

val cameraInfo = Camera.CameraInfo()
Camera.getCameraInfo(cameraId, cameraInfo)
val orientation = cameraInfo.orientation

ซึ่งคำสั่งตรงนี้ไม่ต้องสนใจอะไรมาก เพราะเป็นการคำนวณแบบตายตัว โดยมีรูปแบบคำสั่งดังนี้

fun getCameraDisplayOrientation(activity: Activity, cameraId: Int): Int {
    val cameraInfo: Camera.CameraInfo = Camera.CameraInfo()
    Camera.getCameraInfo(cameraId, cameraInfo)
    val degree = when (activity.windowManager.defaultDisplay.rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> 0
    }
    var orientation = 0
    if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) {
        orientation = (cameraInfo.orientation + degree) % 360
        orientation = (360 - orientation) % 360
    } else if (cameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_BACK) {
        orientation = (cameraInfo.orientation - degree + 360) % 360
    }
    return orientation
}

โดยจะเอาค่า Orientation ของกล้องมาคำนวณกับค่า Orientation ของหน้าจอเพื่อให้รู้ว่าภาพที่ Preview อยู่บนหน้าจอนั้นจะต้องหมุนไปกี่องศาถึงจะได้ภาพที่ไม่กลับหัวกลับหาง

เมื่อได้ค่า Orientation ก็จะต้องไปกำหนดให้กับกล้องด้วยคำสั่ง setDisplayOrientation(...)

val cameraId: Int = Camera.CameraInfo.CAMERA_FACING_FRONT

val camera: Camera? = openCamera(cameraId)
camera?.let { camera: Camera ->
    camera.setDisplayOrientation(getCameraDisplayOrientation(getActivity(), cameraId))
}

กำหนดให้แสดงภาพ Preview แบบ Center Crop

เนื่องจากพื้นที่ Preview บนหน้าจอไม่ได้มีขนาดเท่ากับขนาด Preview ของตัวกล้อง ดังนั้นถ้านำไปแสดงผลเลยก็อาจจะทำให้สัดส่วนภาพผิดเพี้ยนไปได้

ดังนั้นจึงต้องมีการเขียนเพิ่มเล็กน้อยเพื่อปรับให้ภาพเป็นแบบ Center Crop (สามารถเขียนเป็น Fit Center ได้เช่นกัน) โดยที่ TextureView จะมีคำสั่ง setTransform(matrix) เพื่อจัดการเรื่องนี้ให้แล้ว (SurfaceView ไม่มีคำสั่งนี้)

ก่อนอื่นต้องสร้าง Method เพื่อคำนวณขนาดภาพ Preview ให้เป็น Center Crop ก่อน

fun getCropCenterScaleMatrix(
    cameraOrientation: Int,
    viewWidth: Float,
    viewHeight: Float,
    previewWidth: Float,
    previewHeight: Float
): Matrix {
    val scale = getViewScale(
        viewWidth = viewWidth,
        viewHeight = viewHeight,
        previewWidth = if (cameraOrientation == 90 || cameraOrientation == 270) previewHeight else previewWidth,
        previewHeight = if (cameraOrientation == 90 || cameraOrientation == 270) previewWidth else previewHeight
    )
    return Matrix().apply {
        setScale(scale.first, scale.second, viewWidth / 2, viewHeight / 2)
    }
}

private fun getViewScale(
    viewWidth: Float,
    viewHeight: Float,
    previewWidth: Float,
    previewHeight: Float
): Pair<Float, Float> {
    val scaleX: Float
    val scaleY: Float
    val viewRatio = viewWidth / viewHeight
    val previewRatio = previewWidth / previewHeight
    if (previewRatio < viewRatio) {
        scaleX = 1.toFloat()
        scaleY = ((viewWidth * previewHeight) / previewWidth) / viewHeight
    } else {
        scaleX = ((previewWidth / previewHeight) * viewHeight) / viewWidth
        scaleY = 1.toFloat()
    }
    return Pair(scaleX, scaleY)
}

จะเห็นว่าคำสั่ง getCropCenterScaleMatrix(...) ต้องกำหนด Orientation ของกล้องที่ได้จากคำสั่งก่อนหน้านี้ด้วย ทั้งนี้ก็เพราะว่าความกว้างและความสูงของ Preview Size จะเป็นค่าเดิมเสมอ โดยไม่สนใจว่าอุปกรณ์แอนดรอยด์จะแสดงผลในแนวตั้งหรือแนวนอน ดังนั้นจึงต้องเช็คจาก Orientation เพื่อแปลงค่าความกว้างและความสูงของ Preview Size ให้ถูกต้อง

เวลากำหนดให้ภาพ Preview ใน TextureView เป็น Center Crop ก็จะใช้คำสั่งแบบนี้

val cameraOrientation: Int = ...
val viewWidth: Float = ...
val viewHeight: Float = ...
val previewWidth: Float = ...
val previewHeight: Float = ...
textureViewCamera.setTransform(getCropCenterScaleMatrix(cameraOrientation, width, height, previewSize.width, previewSize.height))

รวบคำสั่งกำหนดค่าเริ่มต้นของกล้อง

เนื่องจากไม่ได้ต้องการตั้งค่าอะไรมากนัก จึงกำหนดแค่อันที่จำเป็นๆเท่านั้น และรวมเป็น Method แบบนี้

private val cameraId = Camera.CameraInfo.CAMERA_FACING_BACK
private var camera: Camera? = null
...
private fun setupCamera(width: Int, height: Int) {
    camera = openCamera(cameraId)
    camera?.let { camera: Camera ->
        val cameraOrientation = 
        camera.setDisplayOrientation(getCameraDisplayOrientation(getActivity(), cameraId))
        val parameters = camera.parameters
        val bestPreviewSize: Camera.Size? = getBestPreviewSize(parameters.supportedPreviewSizes, width, height)
        bestPreviewSize?.let { previewSize: Camera.Size ->
            parameters.setPreviewSize(previewSize.width, previewSize.height)
            textureViewCamera.setTransform(
                getCropCenterScaleMatrix(
                    cameraOrientation,
                    width.toFloat(),
                    height.toFloat(),
                    previewSize.width.toFloat(),
                    previewSize.height.toFloat()
                )
            )
        }
        getBestPictureSize(parameters.supportedPictureSizes)?.let { pictureSize: Camera.Size ->
            parameters.setPictureSize(pictureSize.width, pictureSize.height)
        }
        camera.parameters = parameters
    }
}

กำหนด TextureView เพื่อใช้ Preview ภาพจากกล้อง

ในการกำหนด TextureView ให้กับคลาส Camera จะใช้คำสั่ง setPreviewTexture(surfaceTexture)

val camera: Camera? = ...
val surfaceTexture: SurfaceTexture = ...
camera?.setPreviewTexture(surfaceTexture)

เมื่อกำหนด SurfaceTexture เสร็จแล้วก็สามารถแสดงภาพ Preview จากกล้องได้ด้วยคำสั่ง startPreview()

val camera: Camera? = ...
val surfaceTexture: SurfaceTexture = ...
camera?.setPreviewTexture(surfaceTexture)
camera?.startPreview()

รวบคำสั่งกำหนดค่าให้ TextureView แสดงภาพ Preview จากกล้อง

เมื่อกำหนด SurfaceTexture ให้กับคลาส Camera เรียบร้อยแล้ว ก็จะให้เริ่มทำการ Preview ทันที ดังนั้นจึงขอรวบคำสั่งเป็น Method แบบนี้

private var camera: Camera?
...
private fun startCameraPreview(surfaceTexture: SurfaceTexture) {
    try {
        camera?.setPreviewTexture(surfaceTexture)
        camera?.startPreview()
    } catch (e: IOException) {
        Log.e(TAG, "Error start camera preview: " + e.message)
    }
}

เนื่องจากคำสั่ง setPreviewTexture(surfaceTexture) มีโอกาสเกิด IOException ได้ ดังนั้นจึงต้องครอบ Try-catch ไว้เพื่อจัดการกับ IOException ที่เกิดขึ้น

คำสั่งเพื่อเริ่มต้นใช้งาน Camera และ Preview พร้อมแล้ว

สำหรับคำสั่ง setupCamera(width, height) และ startCameraPreview(surfaceTexture) จะเอาไปเรียกใช้งานด้วยกัน 2 ที่ ตามรูปแบบที่เจ้าของบล็อกกำหนดไว้

นั่นก็คือ onStart() ของ Activity/Fragment และ onSurfaceTextureAvailable(...) ของ TextureView

class CameraV1Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */
        textureViewCamera.surfaceTextureListener = surfaceTextureListener
    }

    override fun onStart() {
        /* ... */
        if (textureViewCamera.isAvailable) {
            setupCamera(textureViewCamera.width, textureViewCamera.height)
            startCameraPreview(textureViewCamera.surfaceTexture)
        }
    }

    private val surfaceTextureListener = object : TextureView.SurfaceTextureListener {
        override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
            setupCamera(width, height)
            startCameraPreview(surfaceTexture)
        }
        /* ... */
    }
}

ทำไมต้องเรียกคำสั่งซ้ำ 2 ที่? และทำไมใน onStart ถึงต้องมีการเช็คด้วยคำสั่ง isAvailable()?

ขอทิ้งคำถามน่าฉุกคิดแบบนี้ทิ้งไว้ก่อนนะครับ แล้วเดี๋ยวจะอธิบายในภายหลัง

เมื่อไม่ได้ใช้งาน Camera และ Preview ก็ต้องจัดการให้เรียบร้อยซะ

ทีนี้มาถึงขั้นตอนที่การทำงานของ Camera กับ Preview เสร็จสิ้น ก็ต้องเคลียร์ทิ้งให้เรียบร้อยซะ

โดยจะต้องหยุดการ Preview ด้วยคำสั่ง stopPreview() ก่อน แล้วยกเลิกเรียกใช้งานกล้องด้วยคำสั่ง release()

val camera: Camera? = /* ... */
/* ... */
camera?.stopPreview()
camera?.release()

เจ้าของบล็อกจึงขอรวบเป็น Method แบบนี้

private fun stopCamera() {
    try {
        camera?.stopPreview()
        camera?.release()
    } catch (e: Exception) {
        Log.e(TAG, "Error stop camera preview: " + e.message)
    }
}

โดยคำสั่งดังกล่าวจะถูกเรียกใน onStop() ของ Activity และ onSurfaceTextureDestroyed(...) ของ TextureView

class CameraV1Activity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        /* ... */
        textureViewCamera.surfaceTextureListener = surfaceTextureListener
    }

    override fun onStop() {
        /* ... */
        stopCamera()
    }

    private val surfaceTextureListener = object : TextureView.SurfaceTextureListener {
        /* ... */
        override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
            stopCamera()
            return true
        }
    }
}

ทำไมถึงต้องเรียกคำสั่งซ้ำ 2 ที่?

กลับมายังคำถามที่ค้างคาไว้ก่อนหน้านี้ จะเห็นว่ามีการเรียกคำสั่งซ้ำ 2 ที่

เนื่องจากการทำงานของ TextureView เมื่อถูกแสดงขึ้นมา อาจจะยังไม่ทำงานในทันที จึงต้องมี onSurfaceTextureAvailable(...) เพิ่มเข้ามาเพื่อบอกให้รู้ว่า TextureView พร้อมทำงานแล้ว และเมื่อ TextureView ถูกทำลายก็จะเข้าไปที่คำสั่ง onSurfaceTextureDestroyed(...)

ซึ่งทั้ง 2 Event นี้จะทำงานก็ต่อเมื่อ Activity/Fragment ถูกสร้างขึ้น (Create) และทำลายลง (Destroy) เท่านั้น ในกรณีที่ผู้ใช้กดย่อแอปฯหรือสลับไปใช้งานแอปฯอื่นๆจะไม่มีผลอะไรใดๆ

นั่นล่ะครับ ปัญหาที่เกิดขึ้น เพราะว่า Camera กับ Preview ยังทำงานอยู่ ทั้งๆที่แอปฯถูกย่อไว้แล้ว และปัญหาจะร้ายแรงมากขึ้นเมื่อผู้ใช้สลับไปใช้งานกล้องจากแอปฯตัวอื่น เพราะมันจะใช้งานไม่ได้ เนื่องจากแอปฯของผู้ที่หลงเข้ามาอ่านเรียกใช้งานอยู่

ดังนั้นเจ้าของบล็อกจึงต้องเพิ่มคำสั่งไว้ใน onStart() และ onStop() เพื่อแก้ปัญหานี้ แต่ก็ต้องระวังไม่ให้คำสั่งใน onStart() ถูกเรียกซ้ำกับ onSurfaceTextureAvailable(...) จึงทำให้เจ้าของบล็อกต้องเพิ่มคำสั่งนี้เข้าไป

override fun onStart() {
    /* ... */
    if (textureViewCamera.isAvailable) {
        /* ... */
    }
}

ซึ่งคำสั่งนี้จะช่วยให้คำสั่งกำหนดค่าให้กับ Camera กับ Preview ไม่ทำงานซ้ำซ้อน โดยที่

  • เมื่อ Activity/Fragment ถูกสร้างขึ้นมาครั้งแรก จะเรียกใช้คำสั่งจากใน onSurfaceTextureAvailable(...) โดย SurfaceTexture และขนาดของพื้นที่ Preview จะถูกส่งมาจากคำสั่งดังกล่าว
  • เมื่อ Activity/Fragment ถูกซ่อนแล้วกลับขึ้นมาทำงานใหม่ จะเรียกใช้คำสั่งจากใน onStart() โดย SurfaceTexture จะดึงมาจาก TextureView และขนาดของพื้นที่ Preview ก็จะดึงมาจาก TextureView เช่นกัน

ส่วนกรณีของ onStop() กับ onSurfaceTextureDestroyed(...) จะใช้วิธีต่างกันออกไป นั่นก็คือ…

เคลียร์ Event Listener ของ TextureView ทิ้งด้วย

เพื่อไม่ให้คำสั่งใน onStop() กับ onSurfaceTextureDestroyed(...) ทำงานซ้ำซ้อนกัน ให้เคลียร์ Event Listener ของ TextureView ทิ้งใน onDestroy() ซะ

class CameraV1Activity : AppCompatActivity() {
    ...
    override fun onDestroy() {
        ...
        textureViewCamera.surfaceTextureListener = null
    }
}

สรุป

ในการเรียกใช้งาน Camera นั้นไม่ใช่แค่เรียกคำสั่งเพียงไม่กี่บรรทัด แต่จะต้องมีการกำหนดว่าจะใช้งานกล้องตัวไหน, กำหนด Camera Parameters, สร้าง View สำหรับ Preview ภาพจากกล้อง และอื่นๆอีกมากมาย

โดยในบทความนี้เลือกใช้งาน TextureView เพื่อทำเป็นพื้นที่สำหรับ Preview ภาพจากกล้อง แทนที่จะเป็น SurfaceView แบบที่หลายๆบทความทำกัน ทั้งนี้เพราะว่า SurfaceView นั้นค่อนข้างเก่าและไม่รองรับคำสั่งบางอย่าง (ในขณะที่ TextureView รองรับ) อย่างการกำหนดให้ภาพ Preview เป็นแบบ Center Crop ซึ่งเดิมทีไม่สามารถทำได้ง่ายๆใน SurfaceView

ที่สำคัญที่สุดคือเมื่อเขียนโค้ดเสร็จแล้ว ควรทดสอบการทำงานให้ครบทุกกรณีเท่าที่เป็นไปได้ เพื่อให้แน่ใจว่าแอปฯของผู้ที่หลงเข้ามาอ่านสามารถทำงานได้ปกติในทุกรูปแบบหรือไม่ และสิ่งที่น่าจะเป็นปัญหาบ่อยที่สุดก็คือกล้องของอุปกรณ์แอนดรอยด์แต่ละเครื่องไม่เหมือนกัน ดังนั้นควรทดสอบกับหลายๆเครื่องเพื่อให้แน่ใจว่าโค้ดของผู้ที่หลงเข้ามาอ่านครอบคลุมการทำงานบนอุปกรณ์แอนดรอยด์ทุกรุ่นทุกยี่ห้อ

สำหรับโค้ดในบทความนี้สามารถเข้าไปดูได้ที่ Camera Sample [GitHub] ซึ่งในนี้จะรวมโค้ดในบทความตอนที่ 2 ด้วยนะ

akexorcist/CameraSample
[Android] Example of Camera API v1 and v2 implementation - akexorcist/CameraSample

และนี่คือขั้นตอนการเรียกใช้งานกล้องและแสดงภาพ Preview บนหน้าจอเท่านั้นเองนะ!! ยังไม่ได้เพิ่มคำสั่งให้ถ่ายภาพได้เลย!! ดังนั้นจงตามไปอ่านกันต่อที่ รู้จักและเรียกใช้งาน Camera API v1 บนแอนดรอยด์แบบง่ายๆ [ตอนที่ 2]