iT邦幫忙

2026 iThome 鐵人賽

DAY 13
2

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

旅程到了第 13 天,修正了很多問題,終於要可以將自己的照片放進照片日記裡頭了(雖然範例照片是我的沒錯⋯)。不過,這也代表 Entity 需要更新,連帶的寫入本地儲存空間的程式碼也需要更新。

規劃

本次分為兩個大目標執行:

  1. 要先更新相關的資料結構,讓 DiaryEntry 支援使用者選的照片
  2. 顯示使用者選的照片,包含實作「如何選」

Part 01 實作

照片的來源,可能來自多個地方。本範例就來自於兩處:

  1. 預設的
  2. 使用者自己選的

Kotlin 的 sealed class 很適合做這類型的事情。什麼是 sealed class?請讀者想像成一個主要 class 存「類似的內容」作為子 class。舉個例子:

冰這個大項目裡頭有:甜筒、聖代、霜淇淋、挫冰。則 sealed class 可以寫成:

sealed class 冰 {
	data object 甜筒 : 冰()  // 👈🏼 子 class 可以是一個簡單的 object
	data object 聖代 : 冰()
	data object 霜淇淋 : 冰()
	data class 剉冰(val 紅豆: Int, val 大紅豆: Int, val 芋頭: Int) : 冰()  // 👈🏼 或是一個 data class!
}

透過 Kotlin 的 when 語法,雙劍合璧,把所有可能的項目一字排開:

when (ice) {
	甜筒 -> {
		// 這個 ice 是甜筒
	}
	聖代 -> {
		// 這個 ice 是聖代
	}
	霜淇淋 -> {
		// 這個 ice 是霜淇淋
	}
	is 剉冰 -> {
		// data class 透過 is 來用 
		val azukiBeam = ice.紅豆  // 👈🏼 取得 data class 內容
	}
}

欲了解更多,參考我以前寫的sealed classwhen 語法

好,為何要說明 sealed class 和 when 語法?這裡先來新增一個 sealed class 叫 DiaryPhoto 方便未來擴充:

https://ithelp.ithome.com.tw/upload/images/20260901/20141615BBMZVnIuVw.png

本來在 DiaryEntry 存的是相片資源的 ID (也就是我附上給教學用的那些),現在要改成 DiaryEntry

https://ithelp.ithome.com.tw/upload/images/20260901/20141615Fl3b0ZWw8q.png

ViewModel 的提供的預設資料,也要更新:

https://ithelp.ithome.com.tw/upload/images/20260901/20141615Lz9QxweTsa.png

接著再 UI 上進行調整。原來的 DiaryCard 內容是這樣:

Image(
	painter = painterResource(id = entry.photoResId),
	contentDescription = entry.title,
	contentScale = ContentScale.Crop,
	modifier = Modifier
		.fillMaxWidth()
		.aspectRatio(16f / 9f)
)

這邊就是要擴充的地方。

https://ithelp.ithome.com.tw/upload/images/20260901/20141615xfXP9KTzvD.png

向下相容

讀者讀到這,應該會發現改個檔案結構真是牽一髮而動全身。沒錯,資料流走過的每一處都需要更新,否則就會出問題!

為了考慮舊版的使用者升級 App(沒錯!想像一下!),勢必要做「向下相容」。否則使用者更新完 App 後就⋯ 得到了一個全新的 App。這是通往刪除之路的捷徑,千萬要注意。

原本寫在本機的 JSON 長相是:

{
  "id": "原本的日記 ID",
  "photoResId": 123456,
  "title": "城市縮影裡的警醒"
}

為了擴充,讓資料可以知道是「內建照片」還是「使用者上傳」照片。需要新增一個 photo 區塊:

{
  "id": "原本的日記 ID",
  "photo": {
    "type": "builtIn",  // 👈🏼 像這樣
    "resourceId": 123456
  },
  "title": "城市縮影裡的警醒"
}

但如此一來「讀取」以及「寫入」都必須要調整,「讀取」之處需要做向下相容,也就是讀取到舊的格式,依然要可以作用。

讀取的部分

https://ithelp.ithome.com.tw/upload/images/20260901/201416154YAdJvXDgY.png

在遇到舊的 photoResId 欄位還存在的情況之下,就要走舊版取資料的方式。否則,照上方擴充的規劃,要新增 photo 物件 裡頭有 typeresourceId。這個 resourceId 基本上就是以前的 photoResId 的內容。

寫入的部分

https://ithelp.ithome.com.tw/upload/images/20260901/20141615XHyRPTah9S.png

這一段就只需要把以前寫 photoResId 的地方改成新增 photo JSONObject 就行啦。以上完成了寫入本機的 JSON 更新,如此一來 Entity 也更新支援 DiaryPhoto 啦。


配合分支:day13/photo-modeled,Repo: 按我

Part 02 實作

豪的!接下來就要來支援「讓使用者手動設定照片」啦。新增一個新的 DiaryPhoto 的子 Class:

https://ithelp.ithome.com.tw/upload/images/20260901/20141615bo0QOTDopZ.png

新增了 ExternalReference 的新類別!接著來處理擴充 sealed class 後的連鎖反應,compiler 和 IDE 都會顯示錯誤,在配合 when 語法的地方會說:沒有窮舉所有種類。

先來看看 DiaryPhotoImage 那一塊,需要再實作一個條件給:ExternalReference。不過,先這樣擋一下:

@Composable
private fun DiaryPhotoImage(
    photo: DiaryPhoto,
    contentDescription: String,
    modifier: Modifier
) {
    when (photo) {
        is DiaryPhoto.BuiltIn -> {
            Image(
                painter = painterResource(id = photo.resourceId),
                contentDescription = contentDescription,
                contentScale = ContentScale.Crop,
                modifier = modifier
            )
        }
        is DiaryPhoto.ExternalReference -> {
			// TODO: 加入真正的實作
        }
    }
}

需要新增一個 View 用來顯示照片:

@Composable
private fun ExternalPhotoImage(
    uriString: String,
    contentDescription: String,
    modifier: Modifier
) {
    AndroidView(
        factory = { context ->
            ImageView(context)
        },
        update = { imageView ->
            imageView.contentDescription = contentDescription
            imageView.scaleType = ImageView.ScaleType.CENTER_CROP
            // URI 只是一條外部路徑,先嘗試把它讀成 Bitmap。
            val bitmap = try {
                imageView.context.contentResolver
                    .openInputStream(Uri.parse(uriString))
                    ?.use(BitmapFactory::decodeStream)
            } catch (_: SecurityException) {
                null
            } catch (_: FileNotFoundException) {
                null
            }
            // 👇🏼 URI 失效時回傳 null,讓畫面留白而不讓 App crash;不持久化權限、不複製檔案,也不補預設圖。
            imageView.setImageBitmap(bitmap)
        },
        modifier = modifier
    )
}

這個 View 給人的感覺不同,完全正確!目前用的這套 UI 系統,是 Google 在近期推薦成標準的 Jetpack Compose。然而,還有一套行之有年,從盤古開天開始的元老 UI 元件,本來 Android 的 UI 要透過 XML 來編寫,過去用的元件現在稱為 AndroidView 對比新的 ComposeView

兩者都有互相相容的方法,這裡用一個叫 ImageViewAndroidView 來讀取本機圖片,因為它有一些很方便的寫法,簡單把讀取到的 bitmap 圖塞進去:

val bitmap = try {
	imageView.context.contentResolver
		.openInputStream(Uri.parse(uriString))
		?.use(BitmapFactory::decodeStream)  // 👈🏼 decode 圖片成功,就返回一個 bitmap
} catch (_: SecurityException) {
	null  // 👈🏼 任何失敗,會到這裡
} catch (_: FileNotFoundException) {
	null  // 📚 前面有說明 throw 拋出問題,要用 try catch 這樣接
}

有了這個之後呢,再把原先的 TODO 改成 ExternalPhotoImage()

https://ithelp.ithome.com.tw/upload/images/20260901/20141615yaNltBKAP4.png

本機寫入/讀取更新

又再次感受到ㄧ切息息相關,DiaryPhoto 調整後 LocalDiaryStorage.kt 這裡也需要更新。
寫入資料的部分,就將 ExternalReference 也補上即可。

https://ithelp.ithome.com.tw/upload/images/20260901/20141615z9pYwqXL7q.png

其實讀取的地方也是:

https://ithelp.ithome.com.tw/upload/images/20260901/20141615xZZvtuAp6O.png

讓使用者選擇照片

先更新表單的介面,新增一顆按鈕來讓使用者可以叫出系統的選單來選擇照片。並且配合 DiaryPhotoImage 來做照片的預覽。

https://ithelp.ithome.com.tw/upload/images/20260901/20141615DKcko7Zw2m.png

這個照片不希望機器轉動一下又消失,故將這個狀態放在 ViewModel:

https://ithelp.ithome.com.tw/upload/images/20260901/20141615F6EUyfsQfR.png

最後,Android 需要一種 ActivityResultContracts.PickVisualMedia() ,可以在該 Contracts 的 onResult 部分取得使用者的選擇。這東西用建構子創建出來後,只需要用 launch 就可以把系統的對話框開起來啦。

https://ithelp.ithome.com.tw/upload/images/20260901/20141615ta6pR495UY.png

TL;DR

  • sealed class:一個主 class 底下收「類似的內容」當子 class;今天用它把照片來源(內建、使用者選的)收進 DiaryPhoto,方便未來擴充。
  • data object 與 data class:sealed class 的子 class 可以是單純的 data object,也可以是帶資料的 data class;像 剉冰 帶著紅豆、芋頭,BuiltIn 帶著 resourceId。
  • when 語法:把所有可能項目一字排開的分支寫法;搭配 sealed class 可以讓每種照片都有自己的顯示方式。
  • is:在 when 裡用 is 判斷這個值是不是某個 data class,是就能把裡面的資料拿出來用,例如取出剉冰的紅豆。
  • 窮舉所有種類:sealed class 新增子 class 後,when 沒寫到全部種類時 compiler 會報錯;這個連鎖反應正是在提醒我們還有哪裡沒更新。
  • 向下相容:舊版使用者的資料格式還在,新版讀取時要接得住舊格式;否則更新完 App,使用者就像拿到一個全新(資料全空)的 App。
  • JSON 讀取與寫入都要更新:資料結構從 photoResId 改成 photo 區塊時,讀取要做相容判斷、寫入要改成新格式,改一邊不夠。
  • try / catch:預期可能失敗的操作(讀外部圖片)用 try 包起來、失敗用 catch 接住回傳 null;讓畫面留白,而不是讓 App crash。
  • AndroidView 與 Jetpack Compose 互通:新的 Compose 介面可以嵌入舊的 AndroidView 元件(如 ImageView);舊元件讀本機圖片有現成方便的寫法,不必全部重造。
  • ActivityResultContracts 與 onResult:用 PickVisualMedia() 建出合约、launch 打開系統選照片對話框,使用者的選擇會從 onResult callback 回來;選到的照片狀態要放進 ViewModel,才不會轉個螢幕就消失。

上一篇
[Day12] 把東西讀取回來
下一篇
[Day14] 照片會消失,該怎麼辦?
系列文
我的第一個手作りAndroid App!快樂學習物件導向程式設計!16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
AndyAWD
iT邦新手 1 級 ‧ 2026-09-01 23:15:37

你的帥照怎麼沒有新增上去

這個機會我給了 Windows 98

我要留言

立即登入留言