การพัฒนาแอปบนแอนดรอยด์ในบางครั้ง นักพัฒนาก็อาจจะต้องเก็บข้อมูลลงในอุปกรณ์แอนดรอยด์ให้อยู่ในรูปของไฟล์ด้วยเหตุผลใด ๆ ก็ตาม เช่น ไฟล์ภาพที่ได้จากการใช้งานแอปและต้องการบันทึกลงในเครื่องเพื่อให้ผู้ใช้สามารถนำไปแชร์ต่อได้ เป็นต้น
ดังนั้นในบทความนี้จะมาเล่าเกี่ยวกับการเขียนไฟล์ใด ๆ ลงใน Device Storage ของอุปกรณ์แอนดรอยด์กัน
บทความที่เกี่ยวข้อง
- เรื่องราวของ Device Storage บนแอนดรอยด์ที่นักพัฒนาควรรู้
- การเขียนไฟล์ลงใน Device Storage บนแอนดรอยด์ [Now Reading]
และเพื่อให้เข้าใจเกี่ยวกับ 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 ทำการสแกนไฟล์ดังกล่าวด้วย เพื่อให้แอปอื่น ๆ รับรู้ว่ามีไฟล์ที่ถูกสร้างเพิ่มเข้ามาในเครื่อง
โดยบนแอนดรอยด์จะมีคลาสที่เรียกว่า 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 ที่จะทำหน้าที่จัดการงานเบื้องหลังทั้งหมดให้ เหลือแค่เพียงคำสั่งไม่กี่บรรทัดให้เรียกใช้งาน
โดย Library ตัวนี้จะรองรับตั้งแต่ Android 5.0 Lollipop (API Level 21) ขึ้นไป
สรุป
จากเนื้อหาทั้งหมดจะเห็นว่าการบันทึกไฟล์ลงบนอุปกรณ์แอนดรอยด์นั้นไม่ใช่เรื่องยากอย่างที่คิด (เมื่อเทียบกับการทำงานอย่างอื่นที่มีความซับซ้อนมากกว่านั้น) เพียงแค่ต้องใช้ความเข้าใจเดียวกับ Directory แต่ละประเภท เพราะมีจุดประสงค์ในการใช้งาน, รูปแบบการทำงาน, และคำสั่งที่จะต้องใช้แตกต่างกัน
โดยการบันทึกไฟล์ลงบน Public Directory ของ External Storage เรียกได้ว่าเป็น Directory ที่มีขั้นตอนเยอะที่สุดก็ว่าได้ เพราะจะมีเรื่อง Scoped Storage และ Permission เข้ามาเกี่ยวข้อง ซึ่งจะต้องใช้คำสั่งที่แตกต่างกันสำหรับแอนดรอยด์แต่ละเวอร์ชัน
ดังนั้นเพื่อให้การบันทึกไฟล์สามารถทำงานได้อย่างถูกต้อง ควรทำตาม Best Practice ในเว็ป Android Developers เสมอ เพื่อให้โค้ดรองรับกับแอนดรอยด์ทุกเวอร์ชัน รวมไปถึงการเปลี่ยนแปลงที่อาจจะเกิดขึ้นบนแอนดรอยด์เวอร์ชันใหม่ ๆ ในอนาคตด้วย