MENU

【代码札记】从零开始的安卓应用开发 - P1

April 26, 2023 • 瞎折腾

本文来介绍APP的整体架构和数据源设计。

前文简单描述了这个APP的思路来源和潜在的应用场景,本文首先介绍一下APP的技术和架构。

技术和架构

众所周知,我很讨厌在UI中使用XML来定义布局。我不止一次自己写了一个简单的声明式框架来帮助我渲染UI,这其中就包括我的软件课设和毕业设计。所以同样地,我也讨厌在安卓中使用XML文件来定义布局。

好在Google整出来了一个Jetpack Compose,它基于Kotlin,允许开发者使用声明式的方式定义和渲染布局,并且整个生态支持的相对还不错,在我看在,大部分情况下要比原来的UI更加便于使用。关于数据库和其他框架部分,几乎都支持Kotlin协程,而不用依赖额外的什么RxJava这些。在我看来甚是得体和优雅。

总体来说,APP的界面部分使用Compose来实现,数据库使用Room框架,HTTP请求则使用Retrofit2框架,外观使用Material 3,依赖注入部分使用dagger hilt。对于我的第一个安卓APP来说,这看起来还挺中规中矩的是吧。

关于应用程序的架构,好像我并没有遵循一个特定的准则。我更倾向于凭直觉分割代码。例如数据源的部分分成来自Room数据库的和来自API的;基于这些数据源,针对应用的功能提供类似于Repository、用于提供交互的接口(即业务逻辑不考虑这些数据的原始形式);然后是业务逻辑,通常不考虑UI部分,例如生成条码等;最后是UI部分。我也不知道我这种组织方式叫什么,反正写代码的是我,我自己开心就好。

架构.png

数据源设计

接下来我们说说数据源的设计。数据源分两种:数据库和Memento API。

Memento

在正式开始之前,我们先说一说Memento那边需要怎么建立Library。我打算分为三个Library:位置、容器和物品。

位置部分比较简单,唯一的要求就是具有一个能够区分不同位置的字段。这个字段在Quick Scan操作(指定目的地,通过扫码来向API发出请求,实现将被扫描的物品移动到目标位置)中会被显示给用户,让用户挑选一个位置作为目的地。

容器则相对麻烦一些:容器可能在一个位置下(即位置与容器是1对N),也可能在其他容器里(容器与容器1对N),但这个容器不可能既在一个位置,又在另一个容器里,遗憾的是Memento数据库没办法提供这种检查,但索性我们可以通过编程来修改(设置字段的时候将另一个字段设置为null)。同时容器还要求一个条码字段。

物品与容器类似,但这次条码字段不是必填的,因为考虑到有些物品太小了,没必要贴条码(真的不会有人给每一根数据线都贴上条码吧,不会吧不会吧不会吧)。

除了上述必须的字段,剩余字段可以自由发挥。例如在物品这里可以添加一个勾选框,用来标识这个物品有没有内含电池,这样收纳的时候就可以针对有电池的物品特殊照顾。而这些字段都是和APP不相关的,不会干扰APP的运行。

数据库

首先是数据库设计。由于核心数据都存储在Memento那边,所以APP这块只要记录最基础的数据就好了。

实体

首先是负责记录配置的键值对,这个表比较通用,充当了一个KV存储:

@Entity(
    tableName = "configs",
    primaryKeys = ["config_key"],
    indices = [Index("config_key", unique = true)]
)
data class Config(
    @ColumnInfo(name = "config_key") val key: String,
    @ColumnInfo(name = "config_value") val value: String,
)

在使用时,键的部分通常以scope.namespace.something的形式来表示。例如有关APP中记录最后一次使用的打印机地址可以表示为app.thermal_printer.last_address,而用于访问memento API的密钥则表示为memento.api_key,类似地,记录Memento中存储位置的库的ID可以表示为memento.location.library_id,大概就是这种感觉。值的部分则使用String表示,由具体的设置自行决定。

然后是记录标签的表。在打印的时候我们需要获知正在使用的标签(来自Memento API的数据),同时也要记录我们曾经打印过但还没有在Memento中使用的标签(由我们加入到数据库中)。另外在Quick Scan操作中,我们需要Entity ID来编辑被移动物品的位置,即通过扫描物品的标签,我们需要记录标签到Entity ID的映射。同时基于标签的编码形式,我们不需要区分这个标签是容器标签还是物品标签,因为容器标签总是以字母B开头,而物品标签总是以字母I开头。数据库设计如下:

@Entity(
    tableName = "labels",
    primaryKeys = ["label_id"],
    indices = [
        Index("label_id", unique = true),
        Index("entity_id", unique = true),
        Index("label_status", "version"),
    ]
)
data class Label(
    @ColumnInfo(name = "label_id") val labelId: String,
    @ColumnInfo(name = "label_status") val status: Status,
    @ColumnInfo(name = "version") val version: Long,
    @ColumnInfo(name = "entity_id") val entityId: String?,
)

这里还有一个额外的version字段,这个是为了同步设计的。每次从Memento API拉取数据时,会把获取到的标签的version字段设置为一个时间戳,这样同步结束之后,version字段不一样的标签就是这次同步后被删除的标签。被删除则是因为这个标签曾经在Memento数据库中出现过,但现在消失了,这种场景对应了逻辑上的盒子被丢弃了(可能是被贴上了新的标签,也可能是因为这个盒子被弃用了)。如果是因为网络原因导致这个盒子存在但没有传给APP,那么下次同步的时候还会添加回来,无需担心。

关于标签的状态(status),这是区分一个标签来自Memento的(已被使用),还是来来自于APP的(已打印):

    enum class Status {
        IN_USE, PRINTED
    }

DAO

关于DAO(Data Access Object),在配置那块没什么好说的,增删改查四个函数:

  • 插入或更新
  • 按照key获取配置
  • 删除配置
  • 列出所有配置

在标签部分,除了基础的增删改查四个函数之外,还有:

  • 按照状态删除所有标签:有时候用户可能希望删除所有已打印标签的记录,或者删除所有正在使用的标签以重新同步
  • 按照状态和版本删除标签:在同步完成后,针对所有已使用的标签(有新同步来的,还有需要被删掉的老数据),删除所有version不等于给定version的标签,即删除所有旧的数据

Repository

每个数据库表对应一个Repository,由于不需要什么复杂的变换,因此直接调用DAO就是了。

这是配置的:

interface ConfigRepository {
    suspend fun getConfigByKey(key: String): Config?

    suspend fun insertOrUpdateConfig(config: Config)

    suspend fun deleteConfig(config: Config)

    private suspend fun getConfigOrBlank(settingsKey: SettingsKey): String =
        getConfigByKey(settingsKey.key)?.value ?: ""

    /**
     * Get the item library id (box or item), and the parent location and box fields.
     *
     * Item library id might be blank if config is not set.
     * Fields id might be null if config is not set.
     *
     * @return Triple(itemLibId, itemParentLocationField, itemParentBoxField)
     * */
    suspend fun resolveConfig(prefix: String): Triple<String, Int?, Int?> {
        // ......
    }
}

class ConfigRepositoryRoomImpl @Inject constructor(
    private val dao: ConfigDao
) : ConfigRepository {
    override suspend fun getConfigByKey(key: String): Config? = dao.getConfigByKey(key)

    override suspend fun insertOrUpdateConfig(config: Config) = dao.insertOrUpdateConfig(config)

    override suspend fun deleteConfig(config: Config) = dao.deleteConfig(config)
}

配置部分我在后期额外添加了一个resolveConfig,这个是因为同样的解析配置的操作出现在了多个类中,基于面向对象的考量,这部分代码被抽象放到了ConfigRepository里。这个函数的做用就是给定一个标签,来查询这个标签应该使用的库ID(是容器库还是物品库),以及这个库中表示位置的字段。

这是标签的:

interface LabelRepository {
    suspend fun getLabelById(labelId: String): Label?

    suspend fun insertOrUpdateLabel(label: Label)

    suspend fun deleteLabel(label: Label)

    suspend fun deleteLabelByStatus(status: Label.Status)

    suspend fun deleteOldLabelsByStatus(status: Label.Status, latestVersion: Long)
}

class LabelRepositoryRoomImpl @Inject constructor(
    private val dao: LabelDao
) : LabelRepository {
    override suspend fun getLabelById(labelId: String): Label? = dao.getLabelById(labelId)

    override suspend fun insertOrUpdateLabel(label: Label) = dao.insertOrUpdateLabel(label)

    override suspend fun deleteLabel(label: Label) = dao.deleteLabel(label)

    override suspend fun deleteLabelByStatus(status: Label.Status) = dao.deleteLabelByStatus(status)

    override suspend fun deleteOldLabelsByStatus(status: Label.Status, latestVersion: Long) =
        dao.deleteOldLabelsByStatus(status, latestVersion)
}

Memento API

数据库部分比较简单,接下来说说Memento API的部分。按照官方的说法,这个API还在Beta阶段,不过用起来感觉已经很稳定了。它的搜索接口有一些小bug,但我们用不上这个接口,无所谓。

我们使用的接口有:

  • 列举库:列出当前API key有权访问的所有库
  • 列举库的详情:列出一个给定库的字段数据
  • 列举实体/记录:给定一个库,以分页的方式遍历所有记录
  • 更新记录:给定一个库和实体ID,修改指定的字段的值

需要注意的是,目前API具有Rate limit,个人付费账户是每分钟30次,平均下来也就是2秒一次。我不想做特别复杂的控制逻辑,因此就单纯地在每次请求完成后等待3秒钟,保证不会触发限制。

关于Retrofit怎么使用这里就不多说,这个库真的挺好用的,通过注解就把HTTP请求搞好了:

interface MementoService {
    @GET("v1/libraries")
    suspend fun listLibraries(@Query("token") token: String): ListLibrariesDto

    @GET("v1/libraries/{libraryId}")
    suspend fun getLibraryInfo(
        @Path("libraryId") libraryId: String,
        @Query("token") token: String
    ): GetLibraryDto

    @GET("v1/libraries/{libraryId}/entries")
    suspend fun listEntriesByLibraryId(
        @Path("libraryId") libraryId: String,
        @Query("token") token: String,
        @Query("pageSize") pageSize: Int? = null,
        @Query("pageToken") pageToken: String? = null,
        @Query("fields") fields: String? = null,
        @Query("startRevision") startRevision: Int? = null,
    ): ListEntriesByLibraryIdDto

    @PATCH("v1/libraries/{libraryId}/entries/{entryId}")
    suspend fun updateEntryByLibraryIdAndEntryId(
        @Path("libraryId") libraryId: String,
        @Path("entryId") entryId: String,
        @Body updateEntryDto: UpdateEntryDto,
        @Query("token") token: String
    ): EntryDto
}

你看,这里我只定义了一个接口,用注解表示出对应的HTTP动作,对应的参数和返回值,之后Retrofit就可以自动帮我生成代码来发送HTTP请求,并解析返回值了。关于DTO,写起来也不难,这里我选用的moshi这个库来将Json转换成对象,从响应结果转换到对象也是Retrofit框架负责的一部分,我只需要定义对象就好了,例如:

data class GetLibraryDto(
    @field:Json(name = "id")
    val id: String,
    @field:Json(name = "name")
    val name: String,
    @field:Json(name = "owner")
    val owner: String,
    @field:Json(name = "createdTime")
    val createdTime: String,
    @field:Json(name = "modifiedTime")
    val modifiedTime: String,
    @field:Json(name = "revision")
    val revision: Int,
    @field:Json(name = "size")
    val size: Int,
    @field:Json(name = "fields")
    val fields: List<LibraryField>
) {
    data class LibraryField(
        @field:Json(name = "id")
        val id: Int,
        @field:Json(name = "type")
        val type: String,
        @field:Json(name = "name")
        val name: String,
        @field:Json(name = "role")
        val role: String?,
        @field:Json(name = "order")
        val order: Int
    )
}

Repository

类似地,我们也需要定义一个Repository让业务逻辑无需关心底层实现。在此之前,我们需要处理一些数据类型的转换。例如上面举例的GetLibraryDto.LibraryField,它是作为一个响应体来设计的,因为LibraryField是请求库详情的响应体的一部分。但我们的业务逻辑中也需要获取库的字段列表,如果让业务逻辑拿着这个响应的一部分做事,总觉得好像不太好。因此我们需要单独设计一个业务上的对象:

data class LibraryField(
    val id: Int,
    val type: String,
    val name: String,
) {
    companion object {
        fun fromDto(obj: GetLibraryDto.LibraryField): LibraryField = LibraryField(
            id = obj.id, type = obj.type, name = obj.name
        )
    }
}

这个LibraryField就是业务逻辑中专门描述库字段的模型,虽然定义上和那个响应体差不多,但概念上不同。看起来有些啰嗦,但当程序变得复杂的时候,我想你肯定不希望你的底层API和业务逻辑紧紧绑定在一起。

类似地,我们在列出所有库的时候,我们只希望将有用的信息呈现给业务逻辑:

data class LibraryBrief(
    val id: String,
    val name: String,
    val owner: String
) {
    companion object {
        fun fromDto(obj: ListLibrariesDto.ListLibrariesEntry): LibraryBrief = LibraryBrief(
            id = obj.id, name = obj.name, owner = obj.owner
        )
    }
}

也就是表的ID、表的名称和表的所有者。至于什么修改时间、revision这些,我们目前还不关心。

Repository的设计也与数据库不同,数据库的DAO给出来的数据就可以直接用,但这个API给出的来的数据不能直接交给业务逻辑,例如那个分页遍历。理想情况下,我们希望使用诸如Sequence或者Flow这样的数据结构来表示一个动态生成的数据流(相比之下,List和数组表示所有数据都在内存中等待使用)。换句话说,业务逻辑并不希望了解你的API是怎么分页的,无论是通过pagepageSize,还是通过nextPageToken,业务逻辑期待一个更加规范的包装。Repository的接口是这样的:

interface MementoRepository {
    suspend fun listLibraries(): List<LibraryBrief>

    suspend fun getLibraryFields(libraryId: String): List<LibraryField>

    suspend fun listEntriesByLibraryId(
        libraryId: String,
        pageSize: Int? = null,
        fields: String? = null,
        startRevision: Int? = null,
    ): Flow<List<EntryDto>>

    suspend fun updateEntryByLibraryIdAndEntryId(
        libraryId: String,
        entryId: String,
        updateEntryDto: UpdateEntryDto,
    ): EntryDto
}

可以看到在listEntriesByLibraryId那块,它的返回值是Flow<List<EntryDto>>。那么,什么是Flow呢?

在此之前我们先介绍一下Sequence。序列是Kotlin中提供的一种生成式流,有点类似于Iterator,但使用了协程。关于协程的定义,我也不是什么严谨的学术工作者,所以这里就不献丑了。下面我来举一个例子,希望读者可以从中体会到Sequence和Iterator的不同。

假如我们要按需产生一个从Int最小值到Int最大值的序列,首先来说我们不会把它存在内存里,因为他太多了。但是我们需要在逻辑上使用这样一个序列,怎么办呢?使用Iterator可以实现:

object : Iterator<Int> {
    val counter = AtomicInteger(Int.MIN_VALUE)

    override fun hasNext(): Boolean = counter.get() < Int.MAX_VALUE

    override fun next(): Int = counter.getAndIncrement()
}

我们的迭代器内部有一个计数器。我们通过对比当前计数器的值来判断是否还有下一个元素。我们需要在别人调用next方法时产生下一个元素。

Sequence使用协程来产生元素:

sequence {
    for (i in Int.MIN_VALUE..Int.MAX_VALUE) {
        yield(i)
    }
}

这里的yieldsequence {}函数提供的,它是一个suspend装饰的函数,换句话说它是可以被挂起的。这里我们并不需要考虑别人是否调用了next,我们只需要不停的产出下一个元素。在代码执行时,我们在产出第一个元素的时候(调用yield那块),我们的代码执行就会被暂停,直到我们产出的这个元素被使用了(类似于别人调用了next)。使用之后我们的代码继续执行,产出下一个元素(下一次调用yield),然后又被暂停了,等待这个圆度被消耗(别人调用next)。

在整个过程中,我们不需要考虑别人什么时候调用next,我们只需要考虑怎么产出下一个元素。

在实现上,Sequence和Iterator并没有太大的差距。Iterator的hasNext实际上就是执行sequence里面的方法体,直到方法体调用了一个yield或退出。如果调用了yield,那么就还有下一个;如果退出了,说明方法体执行完毕,没有下一个了。

尽管二者在底层实现上类似,但在概念上完全不同。编写Iterator的时候我们需要考虑别人调用的时机,为了避免数据竞争,我使用了AtomicInteger;但编写Sequence的时候就完全不需要考虑,我只想着如何产生这么多元素就是了。而产生一个连续的数列,最简单的方法就是用for循环。

你看,两种完全不同的思路,完全不同的实现方法,但实现了完全一样的功能。这就是协程的力量。

但是脑子尖的读者可能就要问了:Sequence这么好,那flow又是个什么东西?

问得好,下次不许问了。

Sequence虽好,但它毕竟是发生在一个线程里的事情。别人调用我们的Sequence时,是一个线程在执行不同的代码。我们管调用我们的角色叫消费者好了。在一个线程上,消费者调用了我们的sequence,于是线程开始执行我们的代码,我们的代码调用了yield,于是线程转而继续调用消费者的代码;消费者再次调用我们的sequence请求下一个元素,这时线程暂停执行消费者的代码,转而执行我们的代码来产生下一个元素。

这个过程虽然精妙,但它只发生在一个线程上。幸运的是,产生数字并不需要花费太多时间。但请求API就不一样了。例如我们需要在UI上显示一个列表,这个列表的值源于一个API。如果使用Sequence的话,当UI开始渲染,不断请求元素的时候,负责更新UI的线程就会开始执行我们的代码,请求API。由于涉及到网络IO,在没有得到相应之前,更新UI的线程就会被阻塞,直到API响应,我们的代码将其解析成UI需要的数据,然后执行权回到UI部分。你猜猜看更新UI的线程被阻塞会发生什么事情?更新UI的线程被阻塞,不再响应操作系统的请求,对于用户来说就是APP假死、未响应。如果是了解底细的用户可能会耐心等待,但是不明所以的用户遇到假死的APP,第一反应就是结束应用,这样一来,你的APP看起来就像是一点开下拉列表就假死。很明显,这不是好的用户体验。

那有没有办法在Sequence中使用多线程呢?很遗憾,不能。但幸运的是,我们不止有Sequence,我们还有Flow。Flow是一个冷数据流,我更愿意叫他可重复数据流。和Sequence一样,我们可以随时从头生成这些数据,但是和Sequence不同的是,我们可以使用多线程(Kotlin协程):

flow {
    for (i in Int.MIN_VALUE..Int.MAX_VALUE) {
        emit(i)
        delay(2000)
    }
}

在flow中,我们可以使用其他suspend装饰的函数了。也就是说,我们可以不只局限于生产者和消费者的代码了。从逻辑上说,别人调用我们的flow,我们可以通过emit来产生下一个元素,同时还可以调用delay来将代码的执行权转移到别的地方,比如在IO线程上执行HTTP请求,或者在其他线程上执行数据库操作。

当然,使用flow的另一个好处就是取消。这里我们没有用到,但是取消是一个非常好的特性。以HTTP请求来说,我们不可能无限长时间的等待响应,所以我们应该设定一个超时时间:无论我们有没有获取到全部数据,超过了这个时间,就不再发送HTTP请求了。通过配合withTimeoutOrNull,使用Flow可以轻易的做到这一点。

关于Kotlin协程,我这里只是片面的说了些皮毛。使用Flow还有其他很多好处,例如buffer允许生产者不受限制地产生数据,可以被多线程地处理等。

如果你想深入了解Kotlin协程,这里我推荐阅读霍丙乾编写的《深入理解Kotlin协程》。

P.S.:霍老师记得给我打钱

好像说的有点多了,说回我们的MementoRepository,它的实现如下:

class MementoRepositoryRetrofitImpl @Inject constructor(
    private val service: MementoService,
    private val config: ConfigRepository
) : MementoRepository {
    private val tag = "MementoRepositoryRetrofitImpl"

    private suspend fun getToken(): String =
        config.getConfigByKey(SettingsKey.MEMENTO_API_KEY.key)?.value ?: ""

    override suspend fun listLibraries(): List<LibraryBrief> =
        service.listLibraries(getToken()).libraries.map { LibraryBrief.fromDto(it) }

    override suspend fun getLibraryFields(libraryId: String): List<LibraryField> =
        service.getLibraryInfo(libraryId, getToken()).fields.map { LibraryField.fromDto(it) }

    override suspend fun listEntriesByLibraryId(
        libraryId: String,
        pageSize: Int?,
        fields: String?,
        startRevision: Int?,
    ): Flow<List<EntryDto>> {
        val token = getToken()

        return flow {
            var pageToken: String? = null
            do {
                val r = try {
                    service.listEntriesByLibraryId(
                        libraryId = libraryId,
                        token = token,
                        pageSize = pageSize,
                        pageToken = pageToken,
                        fields = fields,
                        startRevision = startRevision
                    )
                } catch (e: IOException) {
                    Log.e(tag, "Error when fetching entries: $libraryId, $pageToken", e)
                    delay(5000)
                    continue
                } catch (e: HttpException) {
                    Log.e(tag, "Error when fetching entries: $libraryId, $pageToken", e)
                    delay(5000)
                    continue
                }
                val t = System.currentTimeMillis()
                pageToken = r.nextPageToken
                emit(r.entries)
                val dt = System.currentTimeMillis() - t
                // the api has rate limit of 30 request per minute
                // thus wait 2s for each request
                if (dt < 3000) {
                    delay(3000 - dt)
                }
            } while (pageToken != null)
        }
    }

    override suspend fun updateEntryByLibraryIdAndEntryId(
        libraryId: String,
        entryId: String,
        updateEntryDto: UpdateEntryDto
    ): EntryDto = service.updateEntryByLibraryIdAndEntryId(
        libraryId = libraryId,
        entryId = entryId,
        updateEntryDto = updateEntryDto,
        token = getToken()
    )
}

其他函数基本上都是直接调用了HTTP请求,唯独listEntriesByLibraryId使用了flow。我这里并没有使用太花哨的用法,就是简单的记录nextPageToken,然后不断请求下一页。关于错误处理也比较盲目,我简单的假设错误是因为遇到了rate limit,所以出错之后先等几秒钟,然后直接重试。如果获取到了数据,考虑到emit会阻塞线程,所以请求成功那一刻先记录时间,然后从emit处回复执行后再看一次时间,保证继续下一次请求之前等够3秒钟。

总结

虽然还有很多想介绍的内容,但是篇幅有限,只能将余下的内容放在另一篇文章中了。

本次介绍了APP使用的技术和整体架构,随后又说了说数据源的设计。在说明API部分的时候额外多说了一些Kotlin协程的东西。这个在Java里是没有语言级的支持的,需要开发者使用诸如RxJava这些库来做到。而Kotlin中原生提供了协程的支持(suspend关键字),因此在IDE上也有良好的支持(当你看到左边代码行数那块出现一个箭头,你就知道这是一个挂起函数了)。我很喜欢这种简介且自成一体的设计,这可能也是最近几年我一直使用Kotlin的原因。

总之,这一次先写到这里。下一篇文章我想简单说一说安卓上的依赖注入,然后说说业务上的设计。

-全文完-


知识共享许可协议
【代码札记】从零开始的安卓应用开发 - P1天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Last Modified: April 28, 2023
Archives QR Code
QR Code for this page
Tipping QR Code