iT邦幫忙

2026 iThome 鐵人賽

DAY 14
1

配合分支:day14/start,Repo: 按我

鐵人賽已經快到一半囉,前面幾天提到了哪些觀念呢?

細數已經學到的觀念

  • ID:要固定,才能在每次開啟程式後,指出「誰是誰」
  • 資料流:一個行為,走過多少方法,用過多少變數?與使用者操作有關係。
  • 變數:拿來存「狀態」,有可變動 var,和不可變動兩種 val。後面會接變數可以存的資料格式是什麼,叫型態(Type),例如文字就存成 String
  • Class,方法,properties:程式的藍圖,可以做的事情(包含自己做,或是開放給別人使用), 跟著 Class 走的各種狀態,可以記成變數來控制程式碼行為、資料流。
  • Null:空值,代表啥都沒有。通常在變數型態(Type)後會有?
  • 繼承:像是 Activity 就是繼承自 ComponentActivity,建立在別人已經寫好的基礎,站在巨人的肩膀上做擴充。
  • library:一樣是站在巨人肩膀上的行為,不用每次都重新造輪胎。網路上很多功能都有各路的大神寫好了,只需要引入到 App 中就可以使用。

本日任務

雖然現在每次開啟程式,ID 已經固定下來了。代表每次啟動,都能拿到相同的資料。這裡衍伸了其他的問題:有 ID 代表照片一定存在嗎?答案是否定的。

就像查看店家資訊,Google Map 就算是地址存在,也不代表現在還在營業,有可能已經倒了。也就是說,就算照片日記有了某張照片的位置,也不保證那個位置有照片存在。

今天就來強化這件事,為了避免使用者將照片刪除。可以在新增今天的日記時,就將照片複製到「App 的儲存空間」裡頭 (這也是有些 App 會越來越肥的原因呵呵)。順道複習前幾天教過的概念,像是 sealed class 之類的。

新增新的 DiaryPhoto 類型

新增 OwnedFile 代表存到 App 儲存空間的檔案。

data class OwnedFile(val fileName: String) : DiaryPhoto()

先來更新寫入到本機的 JSON 資訊,一樣要做向下相容。

https://ithelp.ithome.com.tw/upload/images/20260902/20141615VR7UGABCi4.png

新增 OwnedFiles 部分,讀取的時候也需要特別注意有這個新的類別。

https://ithelp.ithome.com.tw/upload/images/20260902/20141615erRtUxSTHQ.png

這就完成了寫入 JSON 的部分。

將檔案複製到 App 儲存空間

需要新增一個 class 來做以下兩件事情:

  1. 把檔案複製到 App 儲存空間
  2. 讀取 App 儲存空間的檔案

這一些動作與 Android 系統有關,所以會多花一些篇幅來解釋程式碼。首先,先新增一個 LocalphotoStore class,需要送 Context 進來(因為跟 Android 系統有關)。

class LocalPhotoStore(context: Context) {
    // 👇🏼 App 儲存空間,目前是照片的資料夾啦
    private val photoDirectory = context.filesDir
    // 👇🏼 Android 要透過 content resolver 來取資料
    private val contentResolver = context.contentResolver
}

新增一個 open 方法,這個 Method 讓其他人取得 InputStream (輸入資料串流)。會利用這個 InputStream 來轉成 bitmap 圖片。

fun open(ownedFile: DiaryPhoto.OwnedFile): InputStream {
	return photoDirectory.resolve(ownedFile.fileName).inputStream()
}

import 圖片就比較麻煩了,要先取得目標圖片的 InputStream。接著,在 App 儲存空間的新檔案,要開啟 OutputStream(輸出資料串流)。將原檔案的輸入串流複製到新檔案的輸出串流即可。

想像兩間圖書館,圖書館 A 有一張照片,圖書館 B 也想要館藏。兩間圖書館有傳真機,這時圖書館 A 透過掃描器掃描圖片後,用一個線路把資料一邊掃描一邊提供出去(InputStream)。而圖書館 B 的傳真機收到訊號後,會用同一個線路作為 (OutputStream) 將照片列印出來。(現在還有人知道傳真機嗎⋯)

無論結果如何,最後都要把 stream 關閉(stream.close())。

fun import(
    externalReference: DiaryPhoto.ExternalReference, // 👈🏼 他必須是一個外部檔案
    diaryId: String
): DiaryPhoto.OwnedFile {
    val fileName = "$diaryId.photo"  // 👈🏼 用 id 作為檔案名稱
    val localFile = photoDirectory.resolve(fileName) // 👈🏼 在 App 儲存空間,建立一個檔案
    // 👇🏼 開啟外部檔案的 inputStream
    val inputStream = contentResolver.openInputStream(Uri.parse(externalReference.uriString))
        ?: throw FileNotFoundException(externalReference.uriString)
    // 🖨️ 開啟我們這裡接收的 outputStream
    val outputStream = localFile.outputStream()
    try {
        inputStream.copyTo(outputStream) // 📀 把外部檔案的 stream 倒進去目標檔案的 outputStream
    } finally {
        inputStream.close()
        outputStream.close()
    }
    return DiaryPhoto.OwnedFile(fileName)
}

有了這個 class,存取 App 空間的照片就方便多囉!接著,寫入新的日記時,就把檔案複製到 App 儲存空間即可。

https://ithelp.ithome.com.tw/upload/images/20260902/20141615nfFNTcJ5tk.png

UI 部分更新

DiaryPhotoImage 多了一個種類,這邊還沒有處理。需要新增一個 ComposeView 來預覽 App 儲存空間的照片。依照 Day 13 的做法,這邊把用遠古的 ImageView 來讀取 InputStream 比較方便。

@Composable
private fun OwnedPhotoImage(
    fileName: String,
    contentDescription: String,
    modifier: Modifier
) {
    val context = LocalContext.current
    val photoStore = LocalPhotoStore(context)
    AndroidView(
        factory = { imageContext ->
            ImageView(imageContext)
        },
        update = { imageView ->
            imageView.contentDescription = contentDescription
            imageView.scaleType = ImageView.ScaleType.CENTER_CROP
            val ownedFile = DiaryPhoto.OwnedFile(fileName)
            // 🖨️ 透過 LocalPhotoStore Open 方法來取得 InputStream
            val inputStream = photoStore.open(ownedFile)  
            val bitmap = try {
                BitmapFactory.decodeStream(inputStream) // 👈🏼 decode stream 成 bitmap
            } finally {
                inputStream.close() // 👀 記得最後都要關閉 inputStream
            }
            imageView.setImageBitmap(bitmap)
        },
        modifier = modifier.clipToBounds()
    )
}

有了這個 OwnedPhotoImage 後,再來調整 DiaryPhotoImage 即可。

https://ithelp.ithome.com.tw/upload/images/20260902/20141615nlkHJ0FvLU.png

TL;DR

  • ID 不保證照片還在:有位置或 ID 只代表「記得那裡」,不代表檔案還存在;今天要複製進 App,而不是只存外部位置。
  • OwnedFile:新的 DiaryPhoto 類型,代表已存進 App 儲存空間的檔案,用 fileName 當自己的檔名。
  • Context:跟 Android 系統有關的動作要先送進來,才能拿到儲存資料夾和讀檔能力。
  • filesDir(App 儲存空間):App 自己管的資料夾;複製進來後,使用者刪掉原圖日記還能看。
  • contentResolver:Android 用來開啟外部 Uri 的入口;import 時靠它拿到來源檔。
  • InputStream(輸入資料串流):一邊讀一邊送出的資料線路;open 用它給畫面解圖,import 用它當來源。
  • OutputStream(輸出資料串流):接收端的寫入線路;在 App 儲存空間開新檔,把外部資料倒進去。
  • copyTo 與 close:用 copyTo 把輸入串流倒進輸出串流;無論成功失敗都要在 finallyclose(),線路才不會一直開著。
  • BitmapFactory.decodeStream:把 InputStream 解成 bitmap,OwnedPhotoImage 才能顯示 App 裡的照片。

上一篇
[Day13] 新增我自己的照片
下一篇
[Day15] 錯誤處理
系列文
我的第一個手作りAndroid App!快樂學習物件導向程式設計!16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
AndyAWD
iT邦新手 1 級 ‧ 2026-09-02 23:07:50

contentResolver 有夠惡夢

你今晚的噩夢

我要留言

立即登入留言