本文为现代化 Android 开发系列文章第二篇。

完整目录为:

  • 现代化 Android 开发:根底架构
  • 现代化 Android 开发:数据类(本文)
  • 现代化 Android 开发:逻辑层
  • 现代化 Android 开发:组件化与模块化的抉择
  • 现代化 Android 开发:多 Activity 多 Page 的 UI 架构
  • 现代化 Android 开发:Jetpack Compose 最佳实践
  • 现代化 Android 开发:功能监控

命名

跟着软件复杂度的逐渐提高,正确命名数据类现已是保证代码可读性和可维护性的重要一环了。好的命名,能够清晰的传递其类型和用处,让咱们在运用和查找时都会特别方便。当然,谈到命名,咱们往往会着重要遵循命名规范、运用有意义的姓名、防止缩写和简写。不过这仅仅根底中的根底。咱们还需求在架构的各个环节做好统一命名规范和约束。

咱们这儿以 Article 为例来阐述在各个环节应该怎样命名。

最根底的应该是咱们往数据库存取 ORM 目标,例如:

@Entity
data class Article(
    @PrimaryKey
    val id: Int,
    val title: String,
    val author_id: Int,
    val content: String, 
)
@Entity
data class User(
    @PrimaryKey
    val id: Int,
    val name: String
)

这种数据结构,一般后端也会有一套,假如前后端协议定义得好,那基本上大多数都是通用的,仅仅语言不同罢了,直接用 ChatGPT 转一下就好了。在开发岐黄小筑时,我就需求写 rustkotlinTypeScript 三个端的数据结构,所以代码翻译我用的仍是挺多的,由于涉及多个编辑器写代码,所以我仍是运用 emo-ai 的代码翻译来得方便。

room 是个很好的库,惋惜它诞生于 kotlin-serialization 之前,不然我觉得依赖 kotlin-serialization 来实现是更加完美的,能够复用 kotlin-serialization@SerialName 之类的注解,现在每次相似情况都要打两个注解,着实有点麻烦。

一般而言,咱们的数据是来源于网络,它一般是一个复杂的结构体,那该怎样表明呢?

我的处理方式是加上 Resp 后缀。 例如 Article 的网络成果能够表明为:

@Serializable
data class ArticleResp(
    val article: Article,
    val user: User,
    ... // other fields.
)

通过 -Resp 我就能够知道这是一个网络成果的承载类。 这儿的后台回来成果,我是能够直接复用数据库结构,这是依赖于前后端协议定得比较好,而更多情况,后端回来的数据或许是:

{
    "article": {
        "id": 0,
        "title": "xx",
        "author": {
            // User 字段...
        }
        // 其它字段...
    }
}

这种数据结构方式,便是可读性高一点,网页端由于不需求存储起来做离线,那运用起来就很方便,App 则需求存储到数据库方便离线运用或许增量同步数据,那就需求额定的数据结构来表明,再解析为根底数据结构。这个额定的数据结构我一般加 TO (Transfer Object)后缀来表明:

@Serializable
data class ArticleTO(
    val id: Int,
    val author: User,
    ...
)

假如咱们往服务端 post 数据的话,一般也会不同于根底数据结构,这个时分加 Payload 后缀表明:

@Serializable
data class ArticlePayload(
    val title: String,
    val content: String
)

当咱们需求从数据库读取数据时,咱们需求从几个表中读取数据,这个 Room 也建议运用 POJO 的后缀来组合数据:

data class ArticlePOJO(
    @Embedded
    val ariticle: Article,
    @Relation(...)
    val author: User
)

或许许多人仍是喜爱数据层回来 ArticleTO 这样的数据结构,但假如运用 Room,仍是强制运用 POJO 这种组合方式比较好。

到了 UI 层,或许还需求进一步封装成 UI 舒适的数据结构,那么一般加 VOView Object) 后缀。

总结一下,咱们在不同场景运用不同的后缀来区别数据类:

  • 运用 Resp 后缀表明网络成果类
  • 运用 TO 后缀表明传输数据类
  • 运用 Payload 后缀也是表明传输数据类,主要用 post 恳求的 body 部分,作为恳求的一部分
  • 运用 POJO 后缀表明为查询 room 并组合数据
  • 运用 VO 后缀表明为 UI 而运用的数据类

如此,当咱们看到某个类,就知道其用于什么场景,咱们需求改数据结构,也能够快速查找定位了。

immutable

在曾经,写一个实体类,首要的便是写一堆 setget 办法,但现在推荐的是用 data class 来定义 immutable 的目标。其除了是为了约束运用者随意修改数据外,更多的仍是多线程环境下,mutable 目标太不安稳了。许多偶现的问题,多是由于 mutable 的问题,能够称之为程序员的噩梦。现在 KMM 也有大火的趋势,而它多线程场景只能运用 immutable 目标。所以,早点习气运用 immutable 的编程方式,提前逃离多线程偶现的噩梦。

Jetpack ComposeUI 体系下,Immutable 的重要性就更大了,许多人写出的界面卡顿无比,或许便是用了 Mutable 的数据结构,导致许多时分 recomposition 没办法 skip 掉。

一个简单的比如便是 List 的运用:

@Composable
function ArticleList(list: List<Article>){
    for(...){
        Item(ariticle)
    }
}
@Composable
function ArticlePage(){
    var counter by remember {
        mutableStateOf(0)
    }
    Button(modifier = Modifier.throttleClick {
        counter++
    })
    ArticleList(...)
}

在上面的比如中,ArticlePage 自身有一个计数器,然后运用了 ArticleList 组件,每次计数器增加,触发 ArticlePage 重组,当履行 ArticleList 函数时,它会查看参数是否变更,假如没有变更,那就能够 skip 掉,不然就会触发 ArticleList 函数的从头履行,由于 List 是不安稳的,所以它就会触发 ArticleList 的重组,就会从头跑 for 循环,假如 for 循环的每个 Item 也不是安稳的,那每个 Item 就继续重组,所以整个界面的每个操作都体现得特别卡。

所以还不习气 immutable 开发的同学,写 Jetpack Compose 就会遇到许多坑。

所以咱们得运用 kotlinx-collections-immutable 中的数据结构来替代 ListMap 之类的数据结构,我比较好奇的是 Compose 内部是引入了这个库的,可是并没有对外开放,咱们还得自己引入一次,不知官方的考量是什么。

假如你的数据是 mutable, 除了功能问题,那么最主要的问题仍是子线程修改或许导致的 crash,一个经典的场景便是数组越界:

class A {
    var list = mutableListOf(1, 2, 3, 4, 5)
}
val handled = AtomicBoolean(false)
@Composable
fun Page(a: A) {
    LaunchEffect(Unit){
        withContext(Dispatchers.IO){
            delay(100)
            // 这儿在背景线程移除
            list.removeLast()
            handled.set(true)
        }
    }
    LazyColumn {
        // 这儿通过 `list.size` 确认 有多少个 `item`
        items(a.list.size){
            // 这儿是为了必现而写的死等待操作
            while(!handled.get()){
            }
            // 实际履行到这儿时,由于 `list.size` 现已改变,所以最终一个 `item` 时就数组越界了
            Text(text = "${a.list[it]")
        }
    }
}

在实际开发中,数据更新与渲染机遇都充溢不确认性,所以开发毫无破绽,上线后便是飙升的 crash 率。

所以 rust 的所有权体系真的是巨大,从开发阶段就阻挠了程序员犯错,能编译通过,这种问题便是不或许存在的。

运用 Result 包装

咱们从后端获取成果,或许将数据流交给 UI 层,都会涉及到成功、失利等各种状况,所以用 Result 来包装下数据成果,然后丢给下一层处理,也是一个主流了。

例如网络层成果,我有提供 RespResult 类:

@Serializable
data class RespResult<T>(
    val code: Int,
    val msg: String? = null,
    val data: T? = null
) {
    suspend fun <R> then(errDefault: () -> R? = { null }, block: suspend (Int, T) -> R?): RespResult<R> {
        if (data == null) {
            return RespResult(code, msg, errDefault())
        }
        if (code >= RESP_CODE_OK) {
            return RespResult(code, msg, block(code, data))
        }
        return RespResult(code, msg, errDefault())
    }
    fun isOk(): Boolean {
        return code >= RESP_CODE_OK
    }
}

这个 then 办法的命名,我是抄 rust 的,便是假如成果是成功的,则转换为另一个 RespResult, 不然的话就把失利信息透传到下一层。相当于 map 操作。

逻辑层到 UI 层,我也有 LogicResult 类:

// 加载状况:加载中、本地读取、网络读取、内存 cache 等。
enum class LogicStatus {
    Loading,
    Local,
    LocalButFinal,
    Network,
    Cache
}
data class LogicResult<T>(
    val status: LogicStatus,
    val data: T?,
    val code: Int = RESP_CODE_OK,
    val msg: String? = null
) {
    inline fun <U> then(block: (T) -> U?): LogicResult<U> {
        return LogicResult(status, data?.let(block), code, msg)
    }
}

其实我并不觉得现在这种封装有多么完美,由于对于加载、失利等状况、data 是没有意义的, 并且由于要兼容这些状况,我还必须的让 data 可空。主要仍是由于 kotlinenum 不够强,rustenum 与模式匹配完败 kotlin,假如是 rust, 那这数据结构就能够封装为:

enum LogicResult<T> {
    Loading,
    Local(T),
    Network(T),
    Err(i32, Option<String>)
}

当然,kotlin 也能够封装成相似的,那便是运用 sealed class, 不过由于有泛型,所以对 Loading 等又不能完美的体现。

seal class 许多场景也是很好运用的,例如岐黄小筑的思辨流,或许许多产品都有的动态,或许存在各种 type, 所以许多开发会写出下面的数据类:

data class ThinkVO {
    val id: Int,
    val content: String,
    val type: ThinkType,
    val typRelated1: TypeRelated1? = null,
    val typRelated2: TypeRelated2? = null,
    val typRelated2: TypeRelated2? = null,
}

然后在运用时根据 type 去决定运用哪一个 typRelated 字段,久而久之,就形成了一个巨无霸的类结构。

假如用 sealed class 改造成:

sealed class ThinkVO(val think: Think)
data class TypeRelated1ThinkVO(think: Think, typRelated1: TypeRelated1): ThinkVO(think)
data class TypeRelated2ThinkVO(think: Think, typRelated2: TypeRelated1): ThinkVO(think)
// 运用时通过 when 能够防止分支遗失
when(think){
    is TypeRelated2ThinkVO ->
    is TypeRelated2ThinkVO ->
}

如此,能够防止一堆的 null 查看,假如数据有反常,也是底层进行过滤与打日志,通过 kotlin 语法查看保证上层运用不会出问题。这也是 Result 包装的应用。

总结

本文主要聊了一下编写数据类的三个方面:

  1. 合理的命名
  2. 运用 immutable
  3. 运用 Result 包装

好的数据结构封装,是写好事务逻辑的最根底的一环,希望对我们有所协助,感谢你的阅
读。