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

ดังนั้นในบทความนี้จะมาเล่าเกี่ยวกับการเขียนไฟล์ใด ๆ ลงใน Device Storage ของอุปกรณ์แอนดรอยด์กัน

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

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

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

รู้จักกับ FileOutputStream หัวใจสำคัญในการเขียนไฟล์ลงบนอุปกรณ์แอนดรอยด์

FileOutputStream เป็นหนึ่งใน OutputStream ของ Java I/O ที่สร้างขึ้นมาเพื่อใช้เขียนข้อมูลลงในที่ได้ก็ตามที่กำหนดไว้ในคลาส File หรือ FileDescriptor และในการเขียนไฟล์ลงบนอุปกรณ์แอนดรอยด์ก็จะต้องใช้คลาส FileOutputStream ในการเขียนข้อมูลลงเครื่อง

คลาส FileOutputStream จะมีวิธีใช้งานด้วยรูปแบบที่ง่ายที่สุดแบบนี้

val data: ByteArray = /* ... */
val output: File = /* ... */
FileOutputStream(output).use {
    it.write(data)
}

นักพัฒนาจะต้องสร้างคลาส File เพื่อกำหนดปลายทางที่จะเขียนข้อมูล และมีข้อมูลที่อยู่ในรูปของ ByteArray เพื่อเขียนข้อมูลด้วยคำสั่ง write

และการทำงานของคำสั่งดังกล่าวจะมีโอกาสเกิด Exception อยู่ด้วย จึงทำให้การใช้งานโดยปกติจะต้องมี Exception Handling ด้วยเสมอ

val data: ByteArray = /* ... */
val output: File = /* ... */

try {
    FileOutputStream(output).use {
        it.write(data)
    }
    // File has been written to the destination
} catch (e: NullPointerException) {
    // Handle an error
} catch (e: FileNotFoundException) {
    // Handle an error
} catch (e: SecurityException) {
    // Handle an error
} catch (e: IOException) {
    // Handle an error
}

นอกจากนี้นักพัฒนาสามารถใช้ FileOutputStream ร่วมกับ OutputStream ตัวอื่น ๆ ได้อีกด้วย เช่น ใช้ร่วมกับ ObjectOutputStream เพื่อรับข้อมูลแบบอื่นที่เป็น Primitive Data หรือ Serializable Object ได้ ไม่ต้องแปลงเป็น ByteArray ตลอดเวลาแบบ FileOutputStream

val data: Any = /* ... */
val output: File = /* ... */

try {
    val fos = FileOutputStream(output)
    ObjectOutputStream(fos).use {
        it.writeObject(data)
    }
} catch (e: NullPointerException) {
    // Handle an error
} catch (e: FileNotFoundException) {
    // Handle an error
} catch (e: SecurityException) {
    // Handle an error
} catch (e: IOException) {
    // Handle an error
} catch (e: InvalidClassException) {
    // Handle an error
} catch (e: NotSerializableException) {
    // Handle an error
}

ด้วยเหตุนี้จึงทำให้คลาส FileOutputStream เป็นหัวใจสำคัญในการเขียนไฟล์ลงอุปกรณ์แอนดรอยด์ แต่การจะเขียนไฟล์ลงที่ไหนในเครื่องจะต้องกำหนดผ่านคลาส File ดังนั้นนักพัฒนาจะต้องเขียนคำสั่งเพื่อกำหนด Directory และจัดการเกี่ยวกับ Permission ให้ถูกต้องก่อนที่จะเรียกใช้งานคลาส FileOutputStream ทุกครั้ง

Directory ที่รองรับ

ระบบแอนดรอยด์มีการแบ่ง Directory ออกเป็นหลายส่วน และแต่ละส่วนก็มี Path และจุดประสงค์ที่แตกต่างกัน โดยนักพัฒนาสามารถเลือกใช้งานได้ตามต้องการดังนี้

  • Internal App-specific File Directory
    /data/data/<package_name>/files/
  • Internal App-specific Cache Directory
    /data/data/<package_name>/cache/
  • External App-specific File Directory
    /<external_directory>/Android/data/<package_name>/files/
  • External App-specific Cache Directory
    /<external_directory>/Android/data/<package_name>/cache
  • Shared File ใน Public Directory ของ External Storage
    /<external_directory>/<public_directory>/

และ Directory แต่ละแบบก็จะมีเงื่อนไขและรูปแบบในการใช้งานที่แตกต่างกันออกไปตามตารางข้างล่างนี้

Directory Removed when
clear app cache
Removed when
clear app data
Removed when
app uninstall
Write external storage
permission needed
Access from
other app
Internal
App-specific
File
No Yes Yes No No
Internal
App-specific
Cache
Yes Yes Yes No No
External
App-specific
File
No Yes Yes No Yes
External
App-specific
Cache
Yes Yes Yes No Yes
Shared File No No No Yes (Android 10 or lower)
No (Android 11 or higher)
Yes

ดังนั้นควรเลือกใช้งานให้เหมาะสมกับไฟล์ที่ต้องการบันทึกลงในเครื่อง

การเข้าถึง Directory แต่ละแบบ

คำสั่งที่ใช้สำหรับ Directory แต่ละแบบจะไม่ค่อยแตกต่างกันมากนัก เพราะสุดท้ายแล้วก็คือคลาส File ที่จะส่งต่อให้ FileOutputStream อยู่ดี แต่กรณีที่เป็น Shared File จะมีเรื่อง Permission เข้ามาเกี่ยวข้องด้วย ซึ่งเจ้าของบล็อกจะขอพูดถึง Directory ดังกล่าวแยกเป็นอีกส่วนไปเลย

Internal App-specific Cache Directory และ External App-specific Cache Directory

สำหรับ App-specific Cache Directory สามารถใช้คำสั่งแบบนี้ได้เลย

val context: Context = /* ... */

// Internal app-specific cache directory
val directory: File = context.cacheDir

// External app-specific cache directory
val directory: File = context.externalCacheDir

เรียกได้ว่าเป็น Directory ที่เรียกใช้งานได้ง่ายที่สุดแล้ว เพราะเป็น Directory ที่ผู้ใช้, ระบบแอนดรอยด์, หรือแอปอื่นสามารถสั่งลบข้อมูลข้างในทิ้งเพื่อเพิ่มพื้นที่ว่างในเครื่องได้ตลอดเวลา

Internal App-specific File Directory

จะเหมือนกับ App-specific Cache Directory เลย เพียงแค่ใช้คนละคำสั่งกันเท่านั้น

val context: Context = /* ... */

// Internal app-specific file directory
val directory: File = context.filesDir

External App-specific File Directory

สำหรับ External App-specific File จะต้องมีการกำหนดประเภทของ Subdirectory ด้วยเสมอ เช่น Pictures, Movies, Download, หรือ DCIM เป็นต้น โดยชื่อของ Subdirectory ที่สามารถเรียกใช้งานได้จะอยู่ในคลาสที่ชื่อว่า Environment

val context: Context = /* ... */

// External app-specific file directory (Pictures)
// /<external_directory>/Android/data/<package_name>/files/Pictures/
val directory: File = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)

// External app-specific file directory (Root)
// /<external_directory>/Android/data/<package_name>/files/
val directory: File = context.getExternalFilesDir(null)

Shared File ใน Public Directory ของ External Storage

เนื่องจากเป็นการสร้างไฟล์ที่อยู่ใน Public Directory บน External Storage จึงต้องมีขอสิทธิ์ (Permission) ในการเขียนข้อมูลลง Directory ดังกล่าวสำหรับ Android 10 (API Level 29) หรือเวอร์ชันที่ต่ำกว่า

ส่วน Android 11 (API Level 30) ขึ้นไปไม่ต้องขอสิทธิ์อีกต่อไป เพราะมีการเพิ่ม Scoped Storage เข้ามาจึงทำให้แอปสามารถเขียนไฟล์ลงใน Shared Storage ได้เลย และขอ Permission ตอนอ่านไฟล์แทน

โดย Scoped Storage ถูกเพิ่มเข้ามาตั้งแต่ Android 10 (API Level 29) แต่นักพัฒนาสามารถปิดการทำงานบนเวอร์ชันดังกล่าวได้ และจะถูกบังคับให้ใช้งานบน Android 11 (API Level 30) ที่มีการปรับปรุงวิธีการเรียกใช้งานให้สมเหตุสมผลมากขึ้น ดังนั้นในบทความนี้จะปิด Scoped Storage บน Android 10 (API Level 29) เพื่อให้โค้ดสามารถทำงานได้ถูกต้องตามต้องการ

Scoped Storage เป็นการนำ Concept ของ Application Sandbox มาใช้กับ Device Storage เพื่อให้ผู้ใช้ควบคุมและจัดการกับไฟล์ที่อยู่ใน Shared Storage ได้อย่างมีประสิทธิภาพและปลอดภัยมากขึ้น

ด้วยเหตุนี้จึงต้องเพิ่มคำสั่งไว้ใน Android Manifest เพื่อไม่ให้ Scoped Storage มีผลกับแอปของเราเมื่อทำงานบนอุปกรณ์แอนดรอยด์ที่เป็น Android 10 (API Level 29) และเพิ่ม Permission ที่ชื่อว่า WRITE_EXTERNAL_STORAGE เข้าไปด้วย

<!-- AndroidManifest.xml -->
<manifest ...>

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

    <application 
        ...
        android:requestLegacyExternalStorage="true">
        <!-- ... -->
    </application>
</manifest>

และเนื่องจาก WRITE_EXTERNAL_STORAGE เป็น Permission ที่จำเป็นต้องใช้ก่อนที่จะมี Scoped Storage เพิ่มเข้ามา จึงมีการกำหนด maxSdkVersion ไว้ด้วยว่าเป็น Permission ที่จะใช้สำหรับ Android 10 (API Level 29) หรือต่ำกว่า เพราะ WRITE_EXTERNAL_STORAGE จะไม่มีผลกับเวอร์ชันที่สูงกว่านั้น

ที่กำหนด maxSdkVersion เป็น 29 เพราะว่าปิด Scoped Storage บนเวอร์ชันดังกล่าวไว้ แต่ถ้าเปิดใช้งาน Scoped Storage บนเวอร์ชันนั้นด้วย ก็จะต้องกำหนด maxSdkVersion เป็น 28 แทน

และก่อนที่จะเรียก Public Directory มาใช้งานก็จะต้องขอ Runtime Permission สำหรับ WRITE_EXTERNAL_STORAGE สำหรับ API Level 23 - 29ให้เรียบร้อยก่อนด้วย

val context: Context = /* ... */

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
    Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q &&
    ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
) {
    // Request WRITE_EXTERNAL_STORAGE permission
} else {
    // Shared download directory
    val directory: File = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
}

นอกจากนี้จะเห็นว่าการเรียกใช้งาน Public Directory ก็จะต้องกำหนดประเภทของ Directory ด้วยเช่นกัน โดยในตัวอย่างโค้ดข้างบนจะกำหนดเป็น Environment.DIRECTORY_DOWNLOADS หรือก็คือ /<external_directory>/Downloads  นั่นเอง

กำหนดชื่อไฟล์

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

val directory: File = /* ... */

// ❌ Don't
val destination: File = File(directory.absolutePath + "/report.pdf")

// ✅ Do
val destination: File = File(directory, "report.pdf")

สร้าง Subdirectory

ในบางครั้งนักพัฒนาก็อาจจะต้องการสร้าง Subdirectory ขึ้นมาก่อนแล้วค่อยสร้างไฟล์ไว้ข้างในนั้น แทนที่จะบันทึกลง Root Directory ของ Directory แต่ละแบบโดยตรง

ดังนั้นก่อนที่จะสร้างไฟล์ที่ต้องการ ก็ให้เช็ค Subdirectory ก่อนทุกครั้ง ถ้ายังไม่เคยมีมาก่อนก็ให้สร้างขึ้นมา แต่ถ้ามีอยู่แล้วก็ให้ข้ามไปสร้างไฟล์ต่อได้เลย

สมมติว่าต้องการสร้างไฟล์ไว้ที่ /data/data/<package_name>/files/path/to/file/report.pdf

val context: Context = /* ... */
val directory: File = context.filesDir
val subdirectory: File = File(directory, "path/to/file")

val isDirectoryExists = if (!subdirectory.exists()) {
    subdirectory.mkdirs()
} else true

if (isDirectoryExists) {
    val destination: File = File(subdirectory, "report.pdf")
    // Do something
} else {
    // Directory does not exist
}
การใช้ mkdirs() จะสร้าง Parent Directory ที่ยังไม่เคยมีมาก่อนให้ด้วย ซึ่งจะต่างจาก mkdir() ที่จะสร้างเฉพาะ Most Bottom Directory เท่านั้น (จากในตัวอย่าง Most Bottom Directory คือ file)

Media Scanner กับ Public Directory ของ External Storage

สำหรับการสร้างไฟล์แบบ Shared File หรือไฟล์ที่อยู่ใน Public Directory ของ External Storage จะเป็นไฟล์ที่แอปอื่นสามารถเข้าถึงได้ทันที ดังนั้นทางที่ดีควรสั่งให้ Media Scanner ทำการสแกนไฟล์ดังกล่าวด้วย เพื่อให้แอปอื่น ๆ รับรู้ว่ามีไฟล์ที่ถูกสร้างเพิ่มเข้ามาในเครื่อง

0:00
/

โดยบนแอนดรอยด์จะมีคลาสที่เรียกว่า MediaScannerConnection ให้เรียกใช้งานแบบนี้

val context: Context = /* ... */
val file: File = /* ... */

val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
MediaScannerConnection.scanFile(
    context,
    arrayOf(file.toString()),
    arrayOf(mimeType)
) { _, scannedUri: Uri? ->
    if (scannedUri == null) {
        // File could not be scanned
    } else {
        // File has been scanned
    }
}

ในกรณีที่ scannedUri มีค่าเป็น null จะถือว่า Media Scanner สแกนไฟล์ไม่สำเร็จด้วยเหตุผลบางอย่าง โดยค่าดังกล่าวเป็นคลาส Uri ที่สามารถนำไปใช้งานต่อได้

หรือจะเขียนเป็น Kotlin Coroutines แบบนี้ก็ได้นะ

suspend fun scanFile(context: Context, file: File): Uri {
    val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
    return suspendCancellableCoroutine { continuation ->
        MediaScannerConnection.scanFile(
            context,
            arrayOf(file.toString()),
            arrayOf(mimeType)
        ) { _, scannedUri: Uri? ->
            if (scannedUri == null) {
                continuation.cancel(Exception("File could not be scanned"))
            } else {
                continuation.resume(scannedUri)
            }
        }
    }
}

ทดสอบการทำงาน

ในการทดสอบโค้ดว่าสามารถเขียนไฟล์ได้ถูกต้องหรือไม่ นักพัฒนาสามารถใช้ Device Explorer บน Android Studio เพื่อตรวจสอบความถูกต้องของไฟล์ ซึ่งเป็นเครื่องมือที่สามารถกดเข้าไปดูไฟล์ที่อยู่ใน Internal App-specific Directory ได้ ในขณะที่แอปทั่วไปทำไม่ได้ (หรือต้องรูทเครื่องก่อน)

และกรณีที่เป็น Shared File ที่อยู่ใน Public Directory ของ External Storage ก็อย่าลืมทดสอบด้วยการเปิดแอป File Manager หรือ Image Gallery ดูด้วยว่าไฟล์ดังกล่าวแสดงขึ้นมาทันทีหรือไม่ เพื่อตรวจสอบการทำงานของคำสั่งที่ใช้สำหรับ Media Scanner ด้วย

ขี้เกียจเขียนโค้ดเอง? ลองใช้ FileWriterCompat สิ

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

GitHub - akexorcist/Android-FileWriterCompat: [Android] File writing helper library for API Level 23+
[Android] File writing helper library for API Level 23+ - GitHub - akexorcist/Android-FileWriterCompat: [Android] File writing helper library for API Level 23+

โดย Library ตัวนี้จะรองรับตั้งแต่ Android 5.0 Lollipop (API Level 21) ขึ้นไป

สรุป

จากเนื้อหาทั้งหมดจะเห็นว่าการบันทึกไฟล์ลงบนอุปกรณ์แอนดรอยด์นั้นไม่ใช่เรื่องยากอย่างที่คิด (เมื่อเทียบกับการทำงานอย่างอื่นที่มีความซับซ้อนมากกว่านั้น) เพียงแค่ต้องใช้ความเข้าใจเดียวกับ Directory แต่ละประเภท เพราะมีจุดประสงค์ในการใช้งาน, รูปแบบการทำงาน, และคำสั่งที่จะต้องใช้แตกต่างกัน

โดยการบันทึกไฟล์ลงบน Public Directory ของ External Storage เรียกได้ว่าเป็น Directory ที่มีขั้นตอนเยอะที่สุดก็ว่าได้ เพราะจะมีเรื่อง Scoped Storage และ Permission เข้ามาเกี่ยวข้อง ซึ่งจะต้องใช้คำสั่งที่แตกต่างกันสำหรับแอนดรอยด์แต่ละเวอร์ชัน

ดังนั้นเพื่อให้การบันทึกไฟล์สามารถทำงานได้อย่างถูกต้อง ควรทำตาม Best Practice ในเว็ป Android Developers เสมอ เพื่อให้โค้ดรองรับกับแอนดรอยด์ทุกเวอร์ชัน รวมไปถึงการเปลี่ยนแปลงที่อาจจะเกิดขึ้นบนแอนดรอยด์เวอร์ชันใหม่ ๆ ในอนาคตด้วย

แหล่งข้อมูลอ้างอิง