15 April 2018

แอบส่อง ImageDecoder ใน Android P ที่จะมาแทนที่ BitmapFactory

Updated on


        หลังจากที่ Android P ได้เปิดตัว Preview เพื่อให้นักพัฒนาได้ทดสอบกันแล้ว สิ่งหนึ่งที่เจ้าของบล็อกรู้สึกสนใจก็คือ ImageDecoder ที่จะมาแทนที่ BitmapFactory นี่แหละ

        เดิมทีเวลาเจ้าของบล็อกอยากจะดึงภาพจากซักที่มาทำเป็น Bitmap หรือ Drawable ก็ต้องใช้ BitmapFactory เข้ามาจัดการให้แบบนี้

val filePath = ...
val bitmap = BitmapFactory.decodeFile(filePath)

         อาจจะดูเหมือนง่ายและน่ารัก แต่ถ้าไฟล์ภาพนั้นมีขนาดใหญ่ก็อาจจะทำให้เกิด OutOfMemory ได้ ดังนั้นเจ้าของบล็อกก็ต้องทำตาม Guideline ของ Officie Documentation ว่าด้วยเรื่องการโหลดไฟล์ภาพขนาดใหญ่ๆมาใช้งาน Loading Large Bitmaps Efficiently [Android Developers]


        ในเนื้อหาก็จะบอกเจ้าของบล็อกว่า เฮ้ย เวลาโหลดภาพใหญ่ๆจากที่ไหนมาใช้งานก็ตาม มันอาจจะเกิด OutOfMemory ได้นะ ดังนั้นทางที่ดีก็ควรจะย่อขนาดภาพให้เหมาะกับ View ที่จะเอาไปแสดงผลก่อน จากนั้นอยากจะทำอะไรก็ทำต่อได้เลย

         ซึ่งนั่นคือ Best Practice ที่เจ้าของบล็อกต้องทำตามทุกครั้ง และก็ได้โค้ดออกมาหน้าตาประมาณนี้

private fun doSomethingWithImageResourceThenReturnAsBitmap(FilePath): Bitmap {
    val bitmap = decodeSampledBitmapFromFile(filePath, 200, 200)
    ...
    return ...
}

private fun decodeSampledBitmapFromFile(filePath: String, reqWidth: Int, reqHeight: Int): Bitmap {
    val options = BitmapFactory.Options()
    options.inJustDecodeBounds = true
    BitmapFactory.decodeFile(filePath, options)
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false
    return BitmapFactory.decodeFile(filePath, options)
}

private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val height = options.outHeight
    val width = options.outWidth
    var inSampleSize = 1
    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

      บ้าจริง แค่ดึงภาพมาเป็น Bitmap แท้ๆ ทำไมต้องเขียน Boiledplate Code อะไรแบบนี้ทุกครั้งด้วยเนี่ย ดังนั้นจึงไม่แปลกใจครับที่เนื้อหาในเว็ปของ Official Documentation แนะนำให้เจ้าของบล็อกใช้ Library อย่างพวก Glide, Picasso หรือ Fresco เพื่อลดจำนวนโค้ดและการทำงานต่างๆที่ไม่จำเป็นต้องจัดการเอง

        ในการใช้งานทั่วไป เจ้าของบล็อกก็แนะนำให้ใช้พวก Glide หรือ Picasso น่ะแหละ เพราะมันจัดการง่ายกว่าจริงๆ (แทบไม่ต้องทำอะไรด้วยซ้ำ) แต่ก็ไม่ได้หมายความว่า Library เหล่านั้นจะมาทดแทนได้ทั้งหมดหรอกนะ เพราะสุดท้ายก็จะมีบางอย่างที่นักพัฒนาจะต้องลงไปเขียนเองอยู่ดี

        เพื่อแก้ Painpoint ตรงจุดนี้ นั่นคือที่มาของ ImageDecoder นั่นเอง

ImageDecoder - Modern Image Decoder API

        ImageDecoder ไม่ได้เป็นถึง Library นะ เป็นแค่ API ตัวหนึ่งที่ถูกเพิ่มเข้ามาใน Android P เพื่อให้นักพัฒนาเรียกใช้งานแทน BitmapFactory เท่านั้น เพราะว่าการเรียกใช้งาน BitmapFactory แบบเก่าๆมันค่อนข้างดูล้าสมัยเหลือเกิน

        ซึ่งการทำงานหลักๆของ ImageDecoder จะมีอยู่ 2 ส่วนคือ Source และ Decode

        • Source : เอาไว้กำหนดข้อมูลภาพที่ต้องการดึงมาทำเป็น Bitmap/Drawable
        • Decode : ขั้นตอนการแปลงข้อมูลให้กลายเป็น Bitmap/Drawable


        โดยขั้นตอน Decode ตัว API จะจัดการแปลงภาพเป็น Bitmap/Drawable ให้เอง แต่ที่น่าสนใจก็คือในขั้นตอนนี้จะมี HeaderDecoded เพื่อให้นักพัฒนาสามารถทำอะไรกับ Bitmap ก่อนที่จะโยนออกมาได้ด้วยล่ะ

        เล่าไปก็คงนึกภาพไม่ออก ดังนั้นมาดูโค้ดกันเลยดีกว่า

        ในการสร้าง Source นั้นจะสร้างได้จาก 3 วิธีด้วยกันดังนี้

ImageDecoder.createSource(file: File) : ImageDecoder.Source
ImageDecoder.createSource(buffer: ByteBuffer) : ImageDecoder.Source
ImageDecoder.createSource(contentResolver: ContentResolver, uri: Uri) : ImageDecoder.Source

อ้าว แล้วถ้าจะใช้ภาพจาก Drawable/Mipmap Resource ล่ะ? 

        โคตรน่าเศร้าตรงที่เราต้องแปลงให้กลายเป็น Uri ด้วยตัวเอง

private fun getDrawableUri(resources: Resources, @DrawableRes resourceId: Int): Uri {
    val scheme = ContentResolver.SCHEME_ANDROID_RESOURCE
    val packageName = resources.getResourcePackageName(resourceId)
    val typeName = resources.getResourceTypeName(resourceId)
    val entryName = resources.getResourceEntryName(resourceId)
    return Uri.parse("$scheme://$packageName/$typeName/$entryName")
}

        บ้าจริง!!

        ทีนี้กลับมาต่อในตอนแรก เจ้าของบล็อกมี File ที่ต้องการดึงมาเป็น Bitmap ดังนั้นต้องสร้าง Source ขึ้นมาด้วยคำสั่งแบบนี้

val filePath = ...
val source = ImageDecoder.createSource(File(filePath))

        เมื่อได้ Source แล้ว ก็เอาไป Decode ให้กลายเป็น Bitmap หรือ Drawable ต่อได้เลย

ImageDecoder.decodeBitmap(source: ImageDecoder.Source) : Bitmap
ImageDecoder.decodeBitmap(source: ImageDecoder.Source, listener: ImageDecoder.OnHeaderDecodedListener) : Bitmap
ImageDecoder.decodeDrawable(source: ImageDecoder.Source) : Drawable
ImageDecoder.decodeDrawable(source: ImageDecoder.Source, listener: ImageDecoder.OnHeaderDecodedListener) : Drawable

         เจ้าของบล็อกอยากได้เป็น Bitmap ก็เลยใช้คำสั่ง decodeBitmap(...) แต่ถ้าผู้ที่หลงเข้ามาอ่านอยากจะใช้เป็น Drawable แทน ก็ให้ใช้คำสั่ง decodeDrawable(...) แทน

         ดังนั้นโค้ดของเจ้าของบล็อกจะได้ออกมาเป็นแบบนี้

val filePath = ...
val source = ImageDecoder.createSource(File(filePath))
val bitmap = ImageDecoder.decodeBitmap(source)

        โค้ดสั้นดี เข้าใจไม่ยากเนอะ

        แต่อย่าเพิ่งรีบดีใจไปนะ เพราะว่าถ้าภาพมีขนาดใหญ่เกินไปก็จะเจอปัญหา OutOfMemory อยู่ดี ดังนั้นเจ้าของบล็อกก็จะต้องทำให้ขนาดภาพให้มีขนาดพอเหมาะกับ View ก่อนถึงจะเอาไปแสดง

        ซึ่ง ImageDecoder มี Interface ที่ชื่อว่า OnHeaderDecodedListener เพื่อการนี้นี่แหละ!!

OnHeaderDecodedListener - Decode เสร็จแล้ว อยากจะให้ทำอะไรก็บอกมาได้เลย

        OnHeaderDecodedListener จะช่วยให้นักพัฒนาสามารถทำอะไรกับภาพก็ได้หลังจากที่ Decode เป็น Bitmap/Drawable เสร็จแล้ว

val bitmap = ImageDecoder.decodeBitmap(source, { imageDecoder, imageInfo, source ->
    run {
        // Do something
    }
})

        โดย OnHeaderDecodedListener จะโยนค่าเข้ามา 3 ตัวด้วยกันคือ

        • ImageDecoder : เรียกคำสั่งของ ImageDecoder
        • ImageDecoder.ImageInfo : ข้อมูลของไฟล์ภาพนั้นๆ (ขนาดและ Mime Type)
        • ImageDecoder.Source : Source ของไฟล์นั้นๆ

        ในตัวอย่างนี้เจ้าของบล็อกจะเรียกแค่ ImageDecoder เท่านั้น ดังนั้นขอเปลี่ยนชื่อตัวอื่นๆที่ไม่ได้ใช้ให้เป็น _ แทนนะ (เพราะ Lint มันสั่งมา)

val bitmap = ImageDecoder.decodeBitmap(source, { imageDecoder, _, _ ->
    run {
        // Do something
    }
})

        เพื่อแก้ปัญหา OutOfMemory เพราะภาพใหญ่เกินไป เจ้าของบล็อกจึงใช้คำสั่ง setResize(...) แล้วกำหนดขนาดที่ต้องการจะ Resize ได้เลย

val source = ImageDecoder.createSource(contentResolver, getDrawableUri(resources, imageResourceId))
val bitmap = ImageDecoder.decodeBitmap(source, { imageDecoder, _, _ ->
    run {
        imageDecoder.setResize(200, 200)
    }
})

        อย่างหล่ออ่ะ แต่จะให้ดีกว่านี้ก็โยน Width กับ Height เข้ามาดีกว่า อย่าไปกำหนดเป็นตัวเลขตรงๆแบบนี้เลย
       
        และนอกจากนี้ ImageDecoder ยังมีคำสั่งอื่นๆให้เรียกใช้เรียกงานด้วยนะ

setAllocator(allocator: Int)
setAsAlphaMask(asAlphaMask: Boolean)
setCrop(subset: Rect)
setMutable(mutable: Boolean)
setPostProcessor(processor: PostProcessor)
setResize(sampleSize: Int)
setResize(width: Int, height: Int)
...

        ถ้าอยากรู้ว่ามีอะไรอีกบ้างก็เข้าไปดูเพิ่มเติมกันได้ที่ ImageDecoder [Android Developers]

PostProcessor สำหรับ Canvas Processing

        เมื่อลองสังเกตดีๆจะเห็นว่า ImageDecoder มีคำสั่ง setPostProcessor(...) ด้วย ซึ่งคำสั่งนี้มีไว้ให้นักพัฒนาทำอะไรกับภาพด้วย Canvas ได้ ไม่ต้องไปสร้าง Canvas แล้วเอา Bitmap มาแปะเองทีหลังให้เสียเวลา

imageDecoder.setPostProcessor(PostProcessor { canvas: Canvas ->
    // Do something
})

        หรือจะเขียนสั้นๆแบบนี้ก็ได้

imageDecoder.setPostProcessor({ canvas: Canvas ->
    // Do something
})

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

PixelFormat.OPAQUE
PixelFormat.TRANSLUCENT
PixelFormat.UNKNOWN

        ถ้ารู้ว่าภาพมีพื้นหลังทึบ (พื้นหลังไม่โปร่งใส) ก็ให้ส่งค่าเป็น PixelFormat.OPAQUE และถ้าภาพมีพื้นหลังโปร่งใสก็ให้ส่งค่าเป็น PixelFormat.TRANSLUCENT แต่ถ้าไม่รู้อะไรเลย จะส่งเป็น PixelFormat.UNKONWN ก็ได้นะ ซึ่ง PostProcessor จะเอาค่านี้ไปใช้ใน Process ภาพอีกที

        ในกรณีที่ภาพเป็นพื้นหลังทึบแล้วส่งค่าเป็น PixelFormat.TRANSLUCENT ก็สามารถทำงานได้ปกติ แต่ว่าจะทำงานช้ากว่าปกติ ดังนั้นถ้าเป็นไปได้ก็ควรกำหนดให้ถูกต้องไปซะ

        ห้ามส่งค่าเป็นอย่างอื่นที่ไม่ใช่ OPAQUE, TRANSLUCENT และ UNKNOWN เด็ดขาด เพราะจะทำให้เกิด IllegalArgumentException ได้

ลองเรียกใช้งานทั้งหมดนี้ดีกว่า

         สมมติว่าเจ้าของบล็อกมีภาพแบบนี้อยู่ในเครื่องแบบนี้


         อยากจะดึงภาพมาแสดงใน ImageView ที่มีขนาด 300x150dp โดยใส่ข้อความว่า "Be together. Not the same" ไว้ที่ตรงกลางภาพด้วย ก็จะต้องใช้คำสั่งประมาณนี้

val filePath = ...
val source = ImageDecoder.createSource(File(filePath))
val bitmap = ImageDecoder.decodeBitmap(source, { imageDecoder, _, _ ->
    run {
        imageDecoder.setResize(imageView.measuredWidth, imageView.measuredHeight)
        imageDecoder.setPostProcessor({ canvas: Canvas ->
            val text = "Be together. Not the same"
            val paint = Paint()
            paint.color = Color.WHITE
            paint.textSize = (canvas.height / 8).toFloat()
            paint.isAntiAlias = true
            val bound = Rect()
            paint.getTextBounds(text, 0, text.length, bound)
            val x = (canvas.width - bound.width().toFloat()) / 2
            val y = (canvas.height + bound.height().toFloat()) / 2
            canvas.drawText(text, x, y, paint)
            PixelFormat.OPAQUE
        })
    }
})
imageView.setImageBitmap(bitmap)

        ก็จะได้ผลลัพธ์ออกมาเป็นแบบนี้


AnimatedImageDrawable เพื่อรองรับ Animation บนไฟล์ GIF และ WebP

        นอกจากรูปแบบการเรียกใช้งานของ ImageDecoder แล้ว อีกอย่างหนึ่งที่เจ้าของบล็อกโคตรดีใจเลยก็คือรองรับ GIF/WebP Animation ได้ซะที!!!

        เวลาที่ใช้คำสั่ง decodeDrawable(...) จะมีการเช็คด้วยว่าเป็นไฟล์ GIF/WebP Animation หรือไม่

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

val filePath = ...
val source = ImageDecoder.createSource(File(filePath))
val drawable = ImageDecoder.decodeDrawable(source)
if (drawable is AnimatedImageDrawable) {
    drawable.start()
    ...
}
...

        โดยภาพเคลื่อนไหวจะเล่นก็ต่อเมื่อเรียกคำสั่ง start() เท่านั้น ถ้าไม่ได้สั่งให้มันเล่นมันก็จะใช้ภาพจากเฟรมแรกสุดมาแสดงแทน

        และยังมีคำสั่งอื่นๆด้วยนะ ไปตามอ่านกันได้ใน AnimatedImageDrawable [Android Developers]

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

val filePath = ...
val source = ImageDecoder.createSource(File(filePath))
val drawable = ImageDecoder.decodeDrawable(source, { imageDecoder, _, _ -> 
    run {
        // Do something
    }
})
if (drawable is AnimatedImageDrawable) {
    drawable.start()
    ...
}
...

        รวมไปถึง PostProcess ด้วยนะ​ ถ้ามีการใช้ PostProcess เพื่อใส่ภาพอะไรเพิ่มเข้าไป มันก็จะเพิ่มเข้าไปในทุกเฟรมของภาพนั้นๆทันที

สรุป

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

        แต่ ImageDecoder ก็ไม่ได้ทำงานบน Background Thread นะ ดังนั้นถ้ามีการทำงานหนักๆก็สามารถเกิด Frame Skipping ได้อยู่ดี สุดท้ายก็ต้องไปเขียนใน Background Thread เอาเอง

        ของดีแบบนี้ช่วยเอาไปใส่ไว้ใน Support Library เถอะนะ... ไม่อยากให้รองรับแค่ Android P เลยอ่ะ