iT邦幫忙

2021 iThome 鐵人賽

DAY 4
0
Mobile Development

Jetpack Compose X Android Architecture X Functional Reactive Programming系列 第 4

繪製便利貼以及定義模型

從這一章節開始進入實作的部分,我們要達到的目標是:

  • 可以顯示多張便利貼、而且用手勢來移動他們

針對這個目標,我們當然無法一次就能做到位,而且在這當中還有一些不確定性,其中最大的不確定性就是手勢操作,Jetpack Compose 的技術限制可能會影響多張便利貼的架構。其中的問題是:我們有辦法對單一個 View 做手勢操作嗎?如果答案是肯定的,那我會拿到的座標位置是絕對座標嗎?或是,我們要從 View Parent 來控制全部的手勢操作嗎?那重疊的便利貼我該如何知道?要如何得知誰在上誰在下呢?

還有另一個問題是,要怎麼設計座標系統?Android 原生的座標系統中左上角是原點(0, 0),往右以及往下都是正值,往上以及往右都是負值。在我們的 App 中使用一樣的定義方式嗎?我們要接受負值嗎?這個 app 是有限邊界還是無限邊界呢?

無論如何,我們都需要先做出一個便利貼的 View 才能進行下一步,Jetpack Compose 可以簡單的做到這件事:

Rendering

@Composable
fun StickyNote(content: String) {
    Surface(
        Modifier.size(108.dp, 108.dp),
        color = Color.Green,
        elevation = 8.dp
    ) {
        Column(modifier = Modifier
            .padding(16.dp)
        ) {
            Text(text = content, style = MaterialTheme.typography.h5)
        }
    }
}

@Preview
@Composable
fun StickyNotePreview() {
    StickyNote("Hello")
}

這個 StickyNote 的 UI 由三個部分組合而成: Surface, Column, Text,以下分別解釋:

Surface

依據官方文件的說明,Surface 可以做到以下這幾件事:

  1. Clipping:決定 View 邊界的形狀
  2. Elevation:View 的陰影
  3. Borders:外框
  4. Background:背景
  5. Content Color:內容物的主色

依照以上的說明, Surface 非常適合用來當作便利貼的基底,所以便利貼的大小、陰影、背景色都定義在這一層。便利貼的大小這邊直接定一個寫死的值:108 ,這數值其實沒什麼特別意義,就只是這樣的大小在手機上看來是大小剛好的,那這大小是不是可以調整的呢?目前沒有定義,但這是一個相對好解決的問題,可以放到之後再決定。

Column

下一層我使用了 Column ,我在這裡做了一個假設,便利貼很有可能是垂直排版,通常大家也都是這樣用便利貼的。如果到時候 Column 沒辦法符合需求也沒關係,因為在這邊我也沒有過度設計,可以隨時被取代,現在如果替換成 Box 或是 Row 也會是同樣的結果。這一層最主要的功能只有 Padding 的效果。

Text

最後是 Text ,用來顯示內容,以下是效果圖:

Screen Shot 2021-08-24 at 4.14.08 PM.png

Positioning

在能夠移動之前,得要有辦法將便利貼放在對的位置上,依照我們對 Jetpack Compose 的了解,位置這部分的職責,應該是在 Modifier 身上,於是我們就找到了這個 API : Modifier.offset

Screen Shot 2021-08-24 at 4.35.11 PM.png

用法非常直覺,那就來試試看吧!在 StickyNote 的 function 中再加入另外兩個參數:x 跟 y。

@Composable
fun StickyNote(content: String, **x: Int, y: Int**) {
    Surface(
        Modifier
            **.offset(x = x.dp, y = y.dp)**
            .size(108.dp, 108.dp),
        color = Color.Green,
        elevation = 8.dp
    ) {
        Column(modifier = Modifier
            .padding(16.dp)
        ) {
            Text(text = content, style = MaterialTheme.typography.h5)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun StickyNotePreview() {
    Box(Modifier.fillMaxSize()) {
        StickyNote("Hello", x = 10, y = 10)
        StickyNote("World", x = 80, y = 60)
    }
}

Screen Shot 2021-08-24 at 4.48.08 PM.png

效果看起來不錯!但是為了讓他可以顯示在對的位置上我們多了兩個參數,如果要有更多控制的選項,那參數是不是會越來越多呢?看樣子我們在這邊嗅到了一個壞味道(Bad smell),在程式碼越來越難維護之前,來做的簡單的重構吧!

在 Refactoring 這本書中,這個壞味道叫做 Long Parameter List,我們要採取的做法是 Introduce Parameter Object,也就是說使用一個物件來代表這些 parameter ,那這個物件是什麼呢?我們要幫它想一個最適合的名字,別忘了,命名永遠是工程師所要面對的最困難的問題之一。現在的參數是 x, y, content,這些資訊組合起來是一個什麼樣的概念呢?恩...這個方向好像不太好想,那換個方向想,對於這個 View 來說,我提供給他的資訊應該是什麼?

答案就是便利貼,對吧?所以這個資訊就是便利貼,為了跟 StickyNote 的這個名字作區別,我們將這物件命名為 Note 。根據需求,我們還需要更改顏色,所以 Note 這物件理所當然的應該要包含顏色的資訊,不只如此,我們還有許許多多不同的考量。而這個考量以及定義 Note 的過程,也叫做 Modeling。

Modeling

不管是怎樣的應用程式中,Modeling 都是非常重要的一件事,在開發前期定義 Model 的完整度以及方向會大大的影響之後的整體架構以及維護成本。通常我會有以下的考量:

  1. 這個 Model 是否能夠完全符合需求,如果有不確定的需求,那我前期要怎麼定義才能讓設計錯誤的損失降到最低呢?
  2. 不要有第三方函式庫或是 Android framework 的依賴,像是我們在開發 Android 時很容易使用到原生的 Point 以及Rect 。但是在做單元測試這會是一個負擔,必須要做額外的環境設定才能執行該單元測試,也會花更多時間執行測試。全部使用自己定義的物件還有另一個好處,就是可以根據需求去定義不變性(Invariant[註1]),這樣就不用擔心會有意外的狀態出現在應用程式執行的過程。
  3. Model 跟 Model 之間的關係是什麼?組合關係?一對一還是一對多?有唯一的身份嗎(Unique ID)?套用到 Domain Driven Design 的話,Aggregate 是誰?我要有哪些 Entity 以及 Value Object 呢?
  4. View 可以直接使用這個 Model 嗎?還是需要在中間做一個轉換來更容易在 View 層使用呢[註2]?對於資料層呢?這個 Model 可以同時是個 DTO(Data Transfer Object) 或是 Room Db Entity 嗎?

[註1]Invariant: 以這個 app 為例,如果在座標位置上不允許負值的存在,那麼我們就可以在這個 Model 上進行限制,如果建立了一個有負座標的物件,就丟出一個例外,將錯誤狀態留在觸發的時間點,發現就即時處理,而不是保留這個錯誤狀態,可能在很後面的時間點才知道這邊有發現這個問題,讓 debug 變得相對困難。關於 invariant 有興趣想更深入了解的,可以去看看 Domain Driven Design 相關的書籍。

[註2] 在應用程式的架構設計中,最常使用使用的架構就屬於 Layered-Architecture 了,在不同層級之間,應該要有嚴格的依賴限制還有可見度限制,在商業邏輯層的物件不應該能夠使用顯示層的物件,但是反過來是可以接受的。然而,在一個複雜的應用程式中,顯示層所關注的內容可能小於商業邏輯層所關注的內容,或是要經過特定的資料轉換,這時候,就可以在顯示層有獨立的 Model 來達到關注點分離的效果。

綜合以上的考量,我的決定是不需要有兩個以上定義類似的 Model ,只使用一個 Model 在初期的開發上負擔是最小的,而且他是 Pure Kotlin object,沒有其他 Library 的情況下是能夠獨立存在的,最後,他應該要有個唯一的身份(ID),不然無法跟其他便利貼做出區隔,對於位置上的定義,由於我希望最後的成品是可以拓展到無限邊界的,所以允許有負的位置,以下是我所定義的 Model:

data class Note(
    val id: String,
    val text: String,
    val position: Position,
    val color: YBColor) {

    companion object {
        fun createRandomNote(): Note {
            val randomColorIndex = Random.nextInt(YBColor.defaultColors.size)
            val randomPosition = Position(Random.nextInt(-50, 50).toFloat(), Random.nextInt(-50, 50).toFloat())
            val randomId = UUID.randomUUID().toString()
            return Note(randomId, "Hello", randomPosition, YBColor.defaultColors[randomColorIndex])
        }
    }
}

data class Position(val x: Float, val y: Float) {

    operator fun plus(other: Position): Position {
        return Position(x + other.x, y + other.y)
    }
}

data class YBColor(
    val color: Long
) {
    companion object {
        val HotPink = YBColor(0xFFFF7EB9)
        val Aquamarine = YBColor(0xFF7AFCFF)
        val PaleCanary = YBColor(0xFFFEFF9C)
        val Gorse = YBColor(0xFFFFF740)

        val defaultColors = listOf(HotPink, Aquamarine, PaleCanary, Gorse)
    }
}

以下是 StickyNote 使用 Introduce Parameter Object 這技巧, refactor 後的樣子:

@Composable
fun StickyNote(note: Note) {
    Surface(
        Modifier
            .offset(x = note.position.x.dp, y = note.position.y.dp)
            .size(108.dp, 108.dp),
        color = Color(note.color.color),
        elevation = 8.dp
    ) {
        Column(modifier = Modifier
            .padding(16.dp)
        ) {
            Text(text = note.text, style = MaterialTheme.typography.h5)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun StickyNotePreview() {
    Box(Modifier.fillMaxSize()) {
        StickyNote(Note.createRandomNote())
        StickyNote(Note.createRandomNote())
    }
}

上一篇
Jetpack Compose intro
下一篇
Reactive programming
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30

尚未有邦友留言

立即登入留言