本文来介绍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部分。我也不知道我这种组织方式叫什么,反正写代码的是我,我自己开心就好。
数据源设计
接下来我们说说数据源的设计。数据源分两种:数据库和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是怎么分页的,无论是通过page
和pageSize
,还是通过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)
}
}
这里的yield
是sequence {}
函数提供的,它是一个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 处获得。