配合分支:day13/start,Repo: 按我
旅程到了第 13 天,修正了很多問題,終於要可以將自己的照片放進照片日記裡頭了(雖然範例照片是我的沒錯⋯)。不過,這也代表 Entity 需要更新,連帶的寫入本地儲存空間的程式碼也需要更新。
本次分為兩個大目標執行:
DiaryEntry 支援使用者選的照片照片的來源,可能來自多個地方。本範例就來自於兩處:
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 class,when 語法
好,為何要說明 sealed class 和 when 語法?這裡先來新增一個 sealed class 叫 DiaryPhoto 方便未來擴充:

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

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

接著再 UI 上進行調整。原來的 DiaryCard 內容是這樣:
Image(
painter = painterResource(id = entry.photoResId),
contentDescription = entry.title,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
)
這邊就是要擴充的地方。

讀者讀到這,應該會發現改個檔案結構真是牽一髮而動全身。沒錯,資料流走過的每一處都需要更新,否則就會出問題!
為了考慮舊版的使用者升級 App(沒錯!想像一下!),勢必要做「向下相容」。否則使用者更新完 App 後就⋯ 得到了一個全新的 App。這是通往刪除之路的捷徑,千萬要注意。
原本寫在本機的 JSON 長相是:
{
"id": "原本的日記 ID",
"photoResId": 123456,
"title": "城市縮影裡的警醒"
}
為了擴充,讓資料可以知道是「內建照片」還是「使用者上傳」照片。需要新增一個 photo 區塊:
{
"id": "原本的日記 ID",
"photo": {
"type": "builtIn", // 👈🏼 像這樣
"resourceId": 123456
},
"title": "城市縮影裡的警醒"
}
但如此一來「讀取」以及「寫入」都必須要調整,「讀取」之處需要做向下相容,也就是讀取到舊的格式,依然要可以作用。

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

這一段就只需要把以前寫 photoResId 的地方改成新增 photo JSONObject 就行啦。以上完成了寫入本機的 JSON 更新,如此一來 Entity 也更新支援 DiaryPhoto 啦。
配合分支:day13/photo-modeled,Repo: 按我
豪的!接下來就要來支援「讓使用者手動設定照片」啦。新增一個新的 DiaryPhoto 的子 Class:

新增了 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。
兩者都有互相相容的方法,這裡用一個叫 ImageView 的 AndroidView 來讀取本機圖片,因為它有一些很方便的寫法,簡單把讀取到的 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()

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

其實讀取的地方也是:

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

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

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


DiaryPhoto,方便未來擴充。data object,也可以是帶資料的 data class;像 剉冰 帶著紅豆、芋頭,BuiltIn 帶著 resourceId。when 裡用 is 判斷這個值是不是某個 data class,是就能把裡面的資料拿出來用,例如取出剉冰的紅豆。when 沒寫到全部種類時 compiler 會報錯;這個連鎖反應正是在提醒我們還有哪裡沒更新。photoResId 改成 photo 區塊時,讀取要做相容判斷、寫入要改成新格式,改一邊不夠。try 包起來、失敗用 catch 接住回傳 null;讓畫面留白,而不是讓 App crash。AndroidView 元件(如 ImageView);舊元件讀本機圖片有現成方便的寫法,不必全部重造。PickVisualMedia() 建出合约、launch 打開系統選照片對話框,使用者的選擇會從 onResult callback 回來;選到的照片狀態要放進 ViewModel,才不會轉個螢幕就消失。