绘制便利贴以及定义模型

从这一章节开始进入实作的部分,我们要达到的目标是:

  • 可以显示多张便利贴、而且用手势来移动他们

针对这个目标,我们当然无法一次就能做到位,而且在这当中还有一些不确定性,其中最大的不确定性就是手势操作,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())
    }
}

<<:  鬼故事 - 我不晓得这东西为甚麽会动

>>:  Day1-当水手也得知船长怎样 什麽是k8s

OpenStack 介绍 2

本系列文章同步发布於笔者网站 前一篇文章以比较非技术角度介绍了 OpenStack 这个专案。今天开...

用React刻自己的投资Dashboard Day22 - API与前端资料需求比对

tags: 2021铁人赛 React 一般来说在开发阶段,前端与後端会讨论资料需求,让API产出的...

Python while回圈

我今天要来教大家Python的while回圈,前天有教过for回圈,都是能让程序重复跑的语法,但是功...

【第十天 - UNION型 SQL注入】

Q1. UNION 型 SQL 注入是什麽 SQL 语法中,有个 UNION 的关键字,可以使用它执...

Day-21 : devise 安装 part 2

续昨天的part1, 继续纪录专案过程学会的一些小玩具 config/environments/de...