29 July 2017

[Android Code] รู้จักและเรียกใช้งาน Camera API v1 บนแอนดรอยด์แบบง่ายๆ [ตอนที่ 2]



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

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

        • รู้จักและเรียกใช้งาน Camera API v1 บนแอนดรอยด์แบบง่ายๆ [ตอนที่ 1]
        • รู้จักและเรียกใช้งาน Camera API v1 บนแอนดรอยด์แบบง่ายๆ [ตอนที่ 2]

เหลืออะไรบ้าง?

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

การใช้งาน Camera API v1 (ภาคต่อ)

มาสั่งให้ถ่ายภาพกันเถอะ

        ในการสั่งให้ถ่ายภาพจะมีคำสั่งให้เลือกใช้งานอยู่ 2 แบบดังนี้

val camera: Camera? = ...
...
camera.takePicture(shutterCallback, rawPictureCallback, jpegPictureCallback)
// หรือ
camera.takePicture(shutterCallback, rawPictureCallback, postViewPictureCallback, jpegPictureCallback)

         โดยจะเห็นว่าในคำสั่ง takePicture(...) นั้นจะต้องกำหนด Callback เข้าไปด้วย ซึ่งจะมี Callback หลายแบบมาก

         • Shutter Callback : เมื่อกล้องเริ่มถ่ายภาพ เหมาะสำหรับเล่นเสียงชัตเตอร์
         • Raw Picture Callback : ข้อมูลภาพที่เป็นแบบ RAW จะถูกส่งเข้ามาที่ Callback ตัวนี้ โดยจะเป็น Null ถ้าตัวเครื่องไม่รองรับหรือ Buffer เกิด Overflow
         • Post View Picture Callback : ข้อมูลภาพที่มีการปรับขนาดไว้ให้แล้ว ซึ่งสามารถทำได้เฉพาะอุปกรณ์แอนดรอยด์บางรุ่นเท่านั้น
         • JPEG Picture Callback : ข้อมูลภาพที่บีบอัดเป็น JPEG แล้ว

val camera: Camera? = ...
...
camera?.takePicture({
    // Photo captured from the sensor
}, { data: ByteArray, camera: Camera ->
    // Raw image data (if available)
}, { data: ByteArray, camera: Camera ->
    // Post View image data (if available)
}, { data: ByteArray, camera: Camera ->
    // JPEG image data
})

        แต่ในความเป็นจริงนั้น ผู้ที่หลงเข้ามาอ่านก็ไม่ได้ต้องการ Callback ทุกแบบเสมอไป จึงสามารถกำหนด Callback ที่ไม่ต้องการให้เป็น Null ไปเลยก็ได้

val camera: Camera? = ...
...
camera?.takePicture({
    // Photo captured from the sensor
}, null, null, { data: ByteArray, camera: Camera ->
    // JPEG image data
})

         หรือจะตัด Post View Callback ออกไปเลยก็ได้

val camera: Camera? ...
...
camera?.takePicture({
    // Photo captured from the sensor
}, null, { data: ByteArray, camera: Camera ->
    // JPEG image data
})

        ดังนั้นคำสั่งตอนเรียกใช้งานจริงจะมีลักษณะแบบนี้

val camera: Camera? = ...
...
private fun takePicture() {
    camera?.takePicture({
        playShutterSound()
    }, null, { data: ByteArray, camera: Camera ->
        savePicture(getContext(), data)
    })
}

private fun playShutterSound() {
    ...
}

private fun savePicture(context: Context, data: ByteArray) {
    ...
}

เล่นเสียงชัตเตอร์ในขณะที่กำลังถ่ายภาพ

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

private fun playShutterSound() {
    val sound = MediaActionSound()
    sound.play(MediaActionSound.SHUTTER_CLICK)
}

         โดยคลาส MediaActionSound ถูกเพิ่มเข้ามาใหม่ใน API 16

การสั่งให้โฟกัสภาพ

        เมื่อใดก็ตามที่อยากจะให้โฟกัสภาพใหม่ จะมีคำสั่งง่ายๆแบบนี้

camera?.autoFocus { success: Boolean, camera: Camera ->
    // Play focus sound
}

        เมื่อกล้องทำการโฟกัสภาพเสร็จแล้วก็จะส่ง Callback กลับมาด้วย เพื่อให้ผู้ที่หลงเข้ามาอ่านสามารถใส่คำสั่งอื่นๆอย่างเช่นการเล่นเสียงเมื่อโฟกัสภาพเสร็จแล้ว

        และถ้าอยากให้กล้องทำการโฟกัสภาพแบบต่อเนื่องโดยไม่ต้องสั่งงานเองทุกครั้ง ก็สามารถกำหนด Focus Mode ของกล้องให้เป็นแบบ Continuous Video ได้เลย

Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO

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

fun setupCameraParameter() {
    val parameters: Camera.Parameters = camera.parameters
    ...

    if (isContinuousFocusModeSupported(parameters.supportedFocusModes)) {
        parameters.focusMode = Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO
    }
}

fun isContinuousFocusModeSupported(supportedFocusModes: List<String>?): Boolean {
    return supportedFocusModes?.find { mode -> mode.equals(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO, ignoreCase = true) } != null
}

การบันทึกไฟล์ภาพลงเครื่อง

        เมื่อได้ไฟล์ภาพเป็น JPEG มาแล้ว สิ่งที่ต้องทำต่อก็คือการบันทึกภาพเก็บไว้ในเครื่อง ซึ่งตรงนี้ตัว Camera API ไม่ได้จัดการให้ ดังนั้นผู้ที่หลงเข้ามาอ่าจจะต้องเพิ่มคำสั่งเข้าไปเอง

        ดังนั้นอย่างแรกสุดคือต้องเพิ่ม Permission สำหรับบันทึกไฟล์ลงเครื่องเข้าไปด้วย

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>

    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    ...

</manifest>

        และอย่าลืม Runtime Permission เด็ดขาดล่ะ แต่เนื่องจากโค้ดของ Runtime Permission นั้นค่อนข้างเยอะ ดังนั้นเจ้าของบล็อกจึงเปลี่ยนไปใช้ Library ที่ชื่อว่า Dexter เข้ามาช่วยแทน ดังนั้นโค้ดจะเปลี่ยนเป็นแบบนี้แทน

class SplashScreenActivity : AppCompatActivity() {
    ...
    private fun checkCameraAndWriteExternalPermission() {
        Dexter.withActivity(getActivity())
            .withPermissions(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)
            .withListener(object : MultiplePermissionsListener {
                override fun onPermissionsChecked(report: MultiplePermissionsReport) {
                    if (!report.areAllPermissionsGranted()) {
                        Toast.makeText(getContext(), R.string.camera_and_write_external_storage_denied, Toast.LENGTH_SHORT).show()
                    }
                }

                override fun onPermissionRationaleShouldBeShown(permissions: List<PermissionRequest>, token: PermissionToken) {
                    token.continuePermissionRequest()
                }
            })
            .check()
    }
    ...
}

        โค้ดกระชับขึ้นเยอะ

        เนื่องจากข้อมูลที่ได้จากการถ่ายภาพจะอยู่ในรูปของ Byte Array ดังนั้นการจะบันทึกเป็นไฟล์ลงในเครื่องจะใช้คำสั่งแบบนี้

fun savePicture(context: Context, data: ByteArray) {
    val fileName = "${getCurrentDate()}.jpg"
    val filePath = context.getExternalFilesDir(null)?.absolutePath
    val file = File(filePath, fileName)
    try {
        if (file.exists()) {
            file.delete()
        }
        file.createNewFile()
        val fos = FileOutputStream(file)
        fos.write(data)
        fos.flush()
        fos.close()
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

fun getCurrentDate(): String {
    val simpleDateFormat = SimpleDateFormat("yyyy_MM_dd_HH_mm_ss", Locale.getDefault())
    val date = Date()
    return simpleDateFormat.format(date)
}

        ในตัวอย่างนี้กำหนด Path ของไฟล์ไว้ที่ External Storage ของแอปนั้นๆ ส่วนชื่อไฟล์ก็ตั้งเอาจากวันที่และเวลา ณ ตอนนั้น

แก้ปัญหาภาพกลับด้าน

         ถ้ายังจำกันได้ ตอนภาพเอาจากกล้องมา Preview บนหน้าจอจะมีปัญหาภาพกลับด้าน ดังนั้นก็อย่าแปลกใจอะไรถ้าภาพถ่ายที่บันทึกลงเครื่องก็ดันกลับด้านเหมือนกัน


        แล้วจะแก้ปัญหานี้ยังไงดีล่ะ?

แก้ปัญหาด้วยการทำ Byte Array ให้เป็น Bitmap แล้วหมุนภาพก่อนจะบันทึก

        เจ้าของบล็อกพบว่ามีผู้ที่หลงเข้ามาอ่านบางคนใช้วิธีแบบนี้ โดยแปลง Byte Array ให้กลายเป็น Bitmap ซะ แล้วใช้ Matrix เข้ามาช่วยเพื่อหมุนทิศทางของภาพให้ถูกต้อง แล้วจึงเอา Bitmap ที่ได้ไปบันทึกลงเครื่อง

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

        ดังนั้นเจ้าของบล็อกจึงไม่แนะนำวิธีนี้ซักเท่าไร

แก้ปัญหาด้วยการกำหนดค่า Orientation ลงใน Exif ของไฟล์ภาพ

        วิธีนี้จะไม่ต้องแปลงข้อมูล Byte Array เลย จึงทำให้คำสั่งทำงานได้ไวมาก โดยจะบันทึกไฟล์ลงในเครื่องเหมือนเดิมนั่นแหละ แล้วค่อยแก้ไขค่า Exif ของไฟล์นั้นๆทีหลังเพื่อกำหนด Orientation ให้ถูกต้อง

        ดังนั้นคำสั่ง savePicture(context, data) จะต้องมีการแก้ไขเล็กน้อยเพื่อให้ส่ง Path ของไฟล์ภาพที่บันทึกออกมาด้วย

fun savePicture(context: Context, data: ByteArray): File? {
    val fileName = "${getCurrentDate()}.jpg"
    val filePath = context.getExternalFilesDir(null)?.absolutePath
    val file = File(filePath, fileName)
    try {
        if (file.exists()) {
            file.delete()
        }
        file.createNewFile()
        val fos = FileOutputStream(file)
        fos.write(data)
        fos.flush()
        fos.close()
        return file
    } catch (e: IOException) {
        e.printStackTrace()
    }
    return null
}
...

        และสำหรับคำสั่งในการแก้ไขค่า Orientation ใน Exif ของไฟล์จะเป็นแบบนี้

fun setImageOrientation(file: File?, orientation: Int) {
    file?.let {
        try {
            val exifInterface = ExifInterface(file.path)
            val orientationValue = getOrientationExifValue(orientation).toString()
            exifInterface.setAttribute(ExifInterface.TAG_ORIENTATION, orientationValue)
            exifInterface.saveAttributes()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }
}

fun getOrientationExifValue(orientation: Int): Int {
    return when (orientation) {
        90 -> ExifInterface.ORIENTATION_ROTATE_90
        180 -> ExifInterface.ORIENTATION_ROTATE_180
        270 -> ExifInterface.ORIENTATION_ROTATE_270
        else -> ExifInterface.ORIENTATION_NORMAL
    }
}

        เอ... แล้วจะรู้ได้ยังไงว่าต้องหมุนภาพไปทางไหน?

        อย่าลืมครับ อย่าลืมว่าเจ้าของบล็อกเคยสร้างคำสั่งนี้ไว้ในบทความตอนที่ 1

getCameraDisplayOrientation(activity: Activity, cameraId: Int)

        ดังนั้นคำสั่งทั้งหมดจะออกมาในรูปแบบนี้

private val cameraId: Int = ...
...
private fun savePicture(context: Context, data: ByteArray) {
    savePicture(getContext(), data)?.let { file: File ->
        val orientation = getCameraDisplayOrientation(getActivity(), cameraId)
        setImageOrientation(file, orientation)
        ...
    } ?: run {
        ...
    }
    ...
}

         จะเห็นว่าวิธีนี้จะทำงานได้ไวมาก เพราะไม่ต้องทำอะไรที่ฟุ่มเฟือยอย่างการแปลงภาพเป็น Bitmap โดยวิธีนี้จะไม่ได้หมุนไฟล์ภาพให้ถูกต้องตั้งแต่แรก แต่จะกำหนดค่าไว้ใน Exif เพื่อบอกให้รู้ว่าภาพนี้ต้องหมุนไปทางไหนถึงจะแสดงผลได้อย่างถูกต้อง

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

ไฟล์ภาพไม่แสดงใน Gallery แต่มีไฟล์อยู่ในเครื่อง

        ปัญหาสุดคลาสสิคสำหรับนักพัฒนาแอนดรอยด์ที่จะต้องบันทึกภาพลงในเครื่อง แต่พอไปเปิดดูใน Gallery ของเครื่องแล้วกลับพบว่าหาไฟล์ไม่เจอ แต่พอเปิดดูใน File Explorer ก็พบว่ามีไฟล์อยู่


         สำหรับปัญหานี้สามารถเรียนรู้เพิ่มเติมได้ที่ [Android Code] ทำไมภาพถึงไม่ยอมแสดงใน Gallery

         เพื่อแก้ปัญหาดังกล่าวจึงต้องเพิ่มโค้ดเข้าไปดังนี้

fun updateMediaScanner(context: Context, file: File?) {
    if (file != null) {
        return
    }
    val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE)
    intent.data = Uri.fromFile(file)
    context.sendBroadcast(intent)
}

        และเรียกใช้งานแบบนี้

private val cameraId: Int = ...
...
private fun savePicture(context: Context, data: ByteArray) {
    savePicture(getContext(), data)?.let { file: File ->
        ...
        updateMediaScanner(getContext(), file)
    } ?: run {
        ...
    }
    ...
}

การเปลี่ยนแปลงค่า Parameter ของกล้องระหว่างที่ทำงานอยู่

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

private var camera: Camera? = ...
...
private fun toggleNegativeColor(isTurnOn: Boolean) {
    camera?.let { camera: Camera ->
        val parameters = camera.parameters
        parameters.supportedColorEffects?.let { colorEffectList: List<String> ->
            if (colorEffectList.contains(Camera.Parameters.EFFECT_NEGATIVE)) {
                if (isTurnOn) {
                    parameters.colorEffect = Camera.Parameters.EFFECT_NEGATIVE
                } else {
                    parameters.colorEffect = Camera.Parameters.EFFECT_NONE
                }
            } else {
                Toast.makeText(getContext(), R.string.negative_color_effect_unavailable, Toast.LENGTH_SHORT).show()
            }
        }
        camera.parameters = parameters
    }
}

สรุป

        ในที่สุดก็ครบเรียบร้อยแล้วจ้าาาาา กับพื้นฐาน (จริงๆนะ) การเรียกใช้งานกล้องด้วย Camera API v1 ซึ่งจะเห็นว่านอกจากการเรียกใช้งานกล้องแล้ว ยังมีอีกหลายๆอย่างที่ต้องจัดการเพิ่มด้วยเพื่อให้ทำงานได้สมบูรณ์

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

        สำหรับโค้ดในบทความนี้สามารถเข้าไปดูได้ที่ Camera Sample [GitHub]