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

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

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

เมื่อเรียกใช้งานกล้องได้และแสดงภาพ 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 สำหรับบันทึกไฟล์ลงเครื่องเข้าไปด้วย

<!-- AndroidManifest.xml -->
<?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()
    }
    ...
}
Karumi/Dexter
Android library that simplifies the process of requesting permissions at runtime. - Karumi/Dexter

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

เนื่องจากข้อมูลที่ได้จากการถ่ายภาพจะอยู่ในรูปของ 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 ของเครื่องแล้วกลับพบว่าหาไฟล์ไม่เจอ แต่พอเปิดดูใน File Explorer ก็พบว่ามีไฟล์อยู่

สำหรับปัญหานี้สามารถเรียนรู้เพิ่มเติมได้ที่ ทำไมภาพถึงไม่ยอมแสดงใน 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]

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