本文介绍 APP 的 UI 部分。
前面铺垫了那么多文章,终于到 UI 部分了。
Material 3 展开目录
在开始之前,我想先说说看 Google 的这个设计语言。因为我也不是专业搞前端的,所以在这方面可能说的有些错误,一切以实际为准。
简单地说 Material 3 提供了一系列风格统一的 UI 组件,并针对颜色、字体、形状等要素提供了较为一致的指导。然而在使用 Android studio 创建 APP 时,默认引用的是 Material 2,如果想使用 Material 3 的话,好像还需要折腾一下依赖这些东西。
关于 Material 3 的优点,我认为最大的就是他们的色彩系统。官方有一个调色器,可以根据你上传的壁纸自动生成一系列看起来很融洽的色彩,比我手动挑的好看多了。除了这一点之外,在安卓 13 上还有系统级别的动态配色:也就是说你的 APP 可以根据用户设定的壁纸,自动采用同样颜色的配色,使得整个系统看起来更加统一(我想特立独行的流氓 APP,哦不是,我是说国产 APP,他们应该不会采纳这样的方法)。
我这里就使用了官方的调色器生成了一套主题,但看起来好像并没有使用动态主题,所以还需要手动改一下:
- @Composable
- fun VazanTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
- val useDynamicColors = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
-
- val colors = when {
- useDynamicColors && darkTheme -> dynamicDarkColorScheme(LocalContext.current)
- useDynamicColors && !darkTheme -> dynamicLightColorScheme(LocalContext.current)
- darkTheme -> DarkColors
- else -> LightColors
- }
-
- MaterialTheme(
- colorScheme = colors,
- shapes = Shapes,
- typography = Typography,
- content = content
- )
- }
由于动态配色是安卓 13 才有的,所以需要判断一下。这里我们的逻辑是:
- 如果有动态配色则优先使用动态配色
- 没有则使用 App 自己的配色
至于字体、形状这些,我并没有什么自定义的,就自带的挺好。
权限展开目录
然后再说说看权限。有些权限不需要向用户申请,比如说联网权限。但是有些就需要申请,而用户有可能会拒绝。虽然谷歌建议 APP 应该允许用户拒绝授予权限,并在这种情况下尽力提供服务,但是对于这个 APP 来说,像是蓝牙、相机这种对于功能来说很必须的权限,拒绝了就没法使用了。例如蓝牙权限,既然要通过蓝牙调用打印机打印标签,你不给我蓝牙权限,我怎么和打印机通讯呢?
基于这种观察,我设计了一种以 Activity 为单位的权限申请机制。总地来说权限在进入需要权限的 Activity 之后才申请,并且申请失败直接退出这个 Activity。具体做法就是继承 ComponentActivity
,在里面实现申请权限的逻辑,然后再由其他 Activity 继承这个抽象的 Activity:
- @AndroidEntryPoint
- abstract class VazanActivity : ComponentActivity() {
- protected abstract val permissionExplanation: Map<String, String>
-
- private val requestPermissionLauncher = registerForActivityResult(
- ActivityResultContracts.RequestMultiplePermissions()
- ) { permissions ->
- permissions.forEach { (permission, isGranted) ->
- if (!isGranted) {
- AlertDialog.Builder(this)
- .setTitle("Failed to grant permission")
- .setMessage(
- "Permission: $permission is not granted.\n" +
- "This permission is required for ${permissionExplanation[permission]}."
- )
- .setCancelable(false)
- .setNeutralButton("Fine") { dialog: DialogInterface, _: Int ->
- dialog.dismiss()
- finish()
- }
- .create()
- .show()
- }
- }
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- ensurePermissions(permissionExplanation.keys.toList())
- }
-
- private fun ensurePermissions(permissions: List<String>) {
- val array = permissions
- .filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
- .onEach { require(permissionExplanation.containsKey(it)) { "Unexplainable permission $it" } }
- .toTypedArray()
- if (array.isNotEmpty())
- requestPermissionLauncher.launch(array)
- }
- }
permissionExplanation
是一个 Map,它的键是安卓权限,它的值是一个描述权限用途的字符串。当用户没有授予必要的权限时,向用户说明申请的权限用作何种用途,最后调用 finish()
退出当前 Activity。
UI 组件展开目录
原本想说说看各个 UI 页面的设计,但是后来想了想,这部分大多是布局代码,也没什么好说的。但在构造这部分组件的时候确实做了一些自定义的工作。这里主要针对其中使用到的不常见操作和自定义的部分做出说明。
主界面展开目录
主界面作为功能选单,用户进入到 APP 之后可以直接选取需要的功能并跳转到对应的页面。但是直接丢一个列表又好像不好看,幸好无意中发现了这个叫做 LazyVerticalGrid
的组件。
这个组件与 LazyColumn
差不多,能够按需渲染一个列表,并提供水平或竖直滚动的功能。而这个 Grid,一看就知道是网格了。这个东西用起来有点像是手机的桌面:可以想象每一个 Grid 就是一个图标。这些图标从左上角开始按行排列,如果一行排满了,就自动换到下一行。只需要给出每一个图标需要的最小宽度就可以动态地根据屏幕宽度自动排列。对于罗列功能的菜单来说,这个组件再合适不过了。
但是这个组件并不能随便用:LazyVerticalGrid
只提供了这样一种布局。至于里面每一个元素长什么样子,有什么功能,还得我们自己来写。而且这些元素在外观上具有一定的相似度,但功能又各不相同。所以最好的办法就是把里面这个元素抽象成一个 Composable 函数。这里我用了 Card 来充当按钮,而 Card 内则有一个图标和一行文字,用来描述相应的功能:
- @Composable
- fun GridMenuItem(
- icon: ImageVector,
- text: String,
- onClick: Context.() -> Unit,
- ) {
- val context = LocalContext.current
- Box(
- modifier = Modifier.padding(10.dp).aspectRatio(1f),
- contentAlignment = Alignment.Center
- ) {
- Card(
- onClick = { onClick(context) },
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.primaryContainer
- ),
- modifier = Modifier.fillMaxSize(),
- ) {
- Spacer(modifier = Modifier.weight(0.07f))
- Box(
- modifier = Modifier.fillMaxWidth().weight(0.5f),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = icon, contentDescription = text,
- modifier = Modifier.size(150.dp)
- )
- }
- Box(
- modifier = Modifier.fillMaxWidth().weight(0.3f),
- contentAlignment = Alignment.Center,
- ) {
- Text(text = text, style = MaterialTheme.typography.titleLarge)
- }
- Spacer(modifier = Modifier.weight(0.05f))
- }
- }
- }
这里我用了比较多的 Box,有点类似于 HTML 里面的 div
,我不知道我这样用的对不对,但反正是能用。我也不是专业写前端的,如果用错了,那也就错了。只要不是错的特别离谱,我也不打算改。
为了方便使用,我们当然不想手动渲染每一个 Item,最好是能够给一个列表,然后他就自动去调用这些 Composable 了。为了实现这个目的,得先实现一个描述菜单的结构:
- data class MenuItem(
- val icon: ImageVector,
- val action: String,
- val callback: Context.() -> Unit
- )
对于一个菜单按钮来说,无外乎需要提供三个元素:图标、文字和执行的操作。这里执行的操作给了一个 Context,一开始是打算为了方便调用 startActivity
这些方法,但后来发现这个菜单只在 Activity 里面渲染,而 Activity 本身就是一个 Context 了,好像没必要再这样了。不过写都写了,就这样吧。
接下来封装一下那个 Grid:
- @Composable
- fun GridMenu(
- menuItems: List<MenuItem>,
- modifier: Modifier = Modifier,
- style: GridCells = GridCells.Adaptive(180.dp),
- ) {
- LazyVerticalGrid(
- columns = style, modifier = modifier
- ) {
- items(menuItems) { item ->
- GridMenuItem(icon = item.icon, text = item.action, onClick = item.callback)
- }
- }
- }
这样一来,每次我们需要菜单的之后就直接给一个 List,然后调用这个 GridMenu
就好了,效果如下:
至于实际上主菜单都要有哪些功能,我想了想,就五个:
- 同步:将 Memento 的数据同步到 App 的数据库中
- 打印:打印标签
- 快速扫描(Quick Scan):选定目标位置,将扫描的箱子或物品移动到该位置
- 备份:将 App 数据库导出或导入
- 设置:通过图形界面调整 APP 设置
设置展开目录
设置页面是继主菜单之后最为重要的一个页面。但是我比较懒,不想设计太复杂的组件,所以就采用了列表的方式,每一行一个配置项,标题就是对应的数据库键,值就是内容。
一开始的设计是所有内容都设计成文字,但后来发现像是 Memento 库的 ID,这种东西最好是向 API 获取数据,然后让用户输入,不然 id 看起来就是一个随机字符串,用户很大概率会输入错误,更何况正常的图形界面下用户不可能看到库的 ID。
尽管配置项之间存在差异,但大体思路没有变化:点击一行,弹出修改窗口,应用修改。所以基于这种观察,设计了 ConfigItem
:
- @Composable
- private fun ConfigItem(
- key: String,
- valueProvider: (String) -> String,
- dialogContent: @Composable (MutableState<Boolean>) -> Unit,
- ) {
- val showAlertDialog = rememberSaveable { mutableStateOf(false) }
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(5.dp)
- .clickable { showAlertDialog.value = true }
- ) {
- Text(
- key,
- style = MaterialTheme.typography.titleLarge,
- modifier = Modifier.padding(5.dp)
- )
- Text(
- valueProvider(key),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(10.dp)
- )
-
- if (showAlertDialog.value) {
- dialogContent(showAlertDialog)
- }
- }
- }
这就是两行文字,在被点击时显示弹窗。弹窗的部分使用了 AlertDialog
组件,对于 API Key 这种需要用户输入的,就采用 TextField
,如果是需要选择的,就用 ExposedDropdownMenuBox
:
- ExposedDropdownMenuBox(
- expanded = expanded,
- onExpandedChange = { expanded = !expanded }
- ) {
- TextField(
- value = selected?.let{itemToString(it)} ?: "",
- onValueChange = {},
- readOnly = true,
- trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
- modifier = Modifier.menuAnchor()
- )
-
- ExposedDropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false }
- ) {
- items().forEach {
- DropdownMenuItem(text = {
- Text(text = itemToString(it))
- }, onClick = {
- expanded = false
- selected = it
- })
- }
- }
- }
有些设置的下拉菜单通常具有重复选项。例如我们在选择 Memento 库中的字段时,一个库要选三个字段。但字段不太可能经常变,如果每次选都去 API 拉去数据,一方面 UI 会显得很慢,另一方面 API 会达到 rate limit。所以在 ViewModel 中做一个简单的 Cache:
- private val fieldsMap = ConcurrentHashMap<SettingsKey, SnapshotStateList<LibraryField>>()
- fun getLibraryFields(settingsKey: SettingsKey): SnapshotStateList<LibraryField> {
- val list = fieldsMap.getOrPut(settingsKey) { mutableStateListOf() }
- // a simple cache, so we don't call api multiple times to select field
- if (list.isNotEmpty()) return list
- viewModelScope.launch {
- try {
- val libId = configRepo.getConfigByKey(settingsKey)?.value ?: ""
- mementoRepository.getLibraryFields(libId).let {
- list.clear()
- list.addAll(it)
- }
- } catch (e: IOException) {
- Log.e(tag, "Error when fetching library fields", e)
- showToast("Failed to list library fields: ${e.message}")
- return@launch
- } catch (e: HttpException) {
- Log.e(tag, "Error when fetching library fields", e)
- showToast("Failed to list library fields: ${e.message}")
- return@launch
- }
- }
- return list
- }
当我们以某个配置的值去获取库的字段时,将结果放到 fieldsMap
里。这里没有使用库的 ID,因为获取库 Id 需要执行数据库操作,我们这里需要立刻给 UI 返回一个列表,没办法等数据库操作。这也就导致了如果用户更换了库的 ID,我们可能会用旧的结果返回给新的请求。因此我们需要在更新库 ID 的时候删除对应的缓存。
同步展开目录
有了配置 API 和参数的能力之后,我们就可以开始从 Memento 那边同步数据了。 同步方面没什么好说的,大体步骤如下:
- 从 Memento API 拉取数据,插入或更新数据库,此时 status 为
IN_USE
,version 为同步开始时的时间戳 - 删除所有 status 为
IN_USE
,删除所有 version 不为最新的数据
在 UI 方面也没有太多的设计,不过因为我没有用后台服务,所以不建议用户切换页面,同时为了避免耗时过长自动息屏,在进入同步的 Activity 时自动开启屏幕常亮:
- @AndroidEntryPoint
- class SyncActivity : VazanActivity() {
- // ......
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- // ......
- setContent {
- // keep screen on
- DisposableEffect(key1 = Unit) {
- window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- onDispose {
- window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
- }
- VazanTheme {
- // ......
- }
- }
- }
- }
打印展开目录
数据同步完成之后就可以考虑打印标签的部分了。打印部分分为两个页面:生成标签和打印。
生成标签有三种方式:
- 随机生成:保证生成的标签在数据库中没有出现过
- 扫描:扫描正在使用或已经打印过的标签,一般用于重复打印
- 手动输入:我想不到什么需要手动输入的场景,但是有总比没有好
打印界面主要是和标签编码相关的,确定好类型和标签之后将标签传给打印界面。打印界面将生成打印标签的预览,以及选择打印机、纸张、重复次数等,并实际调用蓝牙联络打印机。
在打印页面返回成功后还会更新数据库,将刚刚打印的标签插入数据库,这时 status 为 PRINTED
,version 为 0。
备份展开目录
前面对着数据库一顿操作,如果数据丢了就糟糕了。好在我们的数据库比较简单,这里我使用了 bencode 来存储数据。关于 Bencode 的编码格式,可以详见之前的文章。
备份文件整体是一个 map,键是数据库表名,值是一个列表,列表内每一个 map 都对应一个记录。相对来说还是比较简单的,但是考虑到数据量可能很大,因此在 Map 和列表处理方面采用了流式处理:
- fun import(reader: Reader): Unit = runBlocking {
- // the import file should only contains 1 map
- // the map is <String, List<Map<String, *>>>
- // <Type, content of objects>
- val decoder = BencodeDecoder(reader)
- decoder.startMap()
-
- // not the end of the map
- while (decoder.nextType() != BEntryType.EntityEnd) {
- val type = decoder.readString()
- require(decoder.hasNext()) { "Invalid map: only has key, no value" }
- when (type) {
- "configs" -> {
- decoder.startList()
- while (decoder.nextType() != BEntryType.EntityEnd) {
- val map = Config.fromMap(readMap(decoder))
- map?.let { configDao.insertOrUpdateConfig(it) }
- }
- decoder.endEntity()
- }
-
- "labels" -> {
- decoder.startList()
- while (decoder.nextType() != BEntryType.EntityEnd) {
- val map = Label.fromMap(readMap(decoder))
- map?.let { labelDao.insertOrUpdateLabel(it) }
- }
- decoder.endEntity()
- }
-
- else -> error("Unknown type: $type")
- }
- }
- // end of the map
- decoder.endEntity()
- }
快速扫描展开目录
快速扫描部分针对目的地需要分别设计。
如果目的地是一个位置,那么需要从 Memento API 拉去目前已知的位置,通过下拉列表让用户选择一个;如果目的地是一个容器,那么需要让用户扫描容器的条码。对于实际扫描移动的部分,逻辑上是没差的:扫描、发出请求将位置或容器设置为目的地,然后清空另一个字段。
关于选择目的地,我直接套用了主界面的菜单格式。如果选择位置的话,则直接通过下拉列表选择,如果选择箱子的话则调用 GMS 的扫码服务进行扫码。
GMS 的扫码服务是通过调用 Google Play 服务完成的,因此 APP 不需要请求相机权限即可完成扫码。就目前的体验来说,它的扫码速度是最快的。但是我在测试过程中发现 Google Play 服务经常会自废武功。一开始扫码还能用,不知道后台什么时候一个自动更新,这个扫码就不能用了,这个时候就得手动清空 Google Play 服务的数据(不会登出 Google 账号),然后重新请求扫码服务,就很麻烦。
如果谷歌在之后几个月还是修不好这个 bug 的话,我可能还是要考虑在应用内申请摄像头权限来扫码了。
目的地选择完成之后会获得一个 Memento 的 Entity ID,接下来我们拉起负责扫码和发送请求的 Activity,并且把这个 Entity ID 和目的地类型传过去。根据目的地类型的不同,发出的请求也会有所变化:
- 如果目的地是位置,则将箱子或物品的父级位置设置为 Entity ID,设置父级容器为 null
- 如果目的地是箱子,则将箱子或物品的父级容器设置为 Entity ID,设置父级位置为 null
而扫码的部分也相对简单:
- 调用 GMS 扫码服务
- 根据扫描到的条码,查询数据库中是否存在该标签,并且该标签是否存在对应的 Entity ID
- 若该条码存在,则判断条码的类型,查询设置中对应的库 ID 和字段 ID
- 根据目的地发出请求
- 请求成功后再次调用扫码服务,回到 step1
这部分是一个循环,用户可以在扫码阶段执行返回操作,进而取消扫码。这时扫码部分的 Activity 就会顺势结束,退出快速扫描的操作。
总结展开目录
至此,关于我的第一个安卓 APP 就已经全部讲解完了。这一片拖得有点久,因为一直没有想明白 UI 部分到底应该怎么写。关于布局的代码又臭又长,而且还没什么新意,但是有些东西用起来很自然,写起来不一定自然,可是说不自然吧,又没达到复杂的程度,这种东西拿出来写显得啰嗦,不拿出来写又觉得过于省略了,实在是很难定夺。最后我觉得还是不要事无巨细的说比较好,毕竟 UI 做出来是要用的,不是拿来吹的。在交互上有不少细节,你不提没人知道,你加上了别人会觉得很顺畅很舒服,但是你提起来又会显得很啰嗦。
目前这个 APP 已经完全开源在了 GitHub 上。虽然对于开源软件我不提供任何质量和可用性的保障,但是这个 APP 毕竟是我自己用的 APP,能用肯定是基本要求。
- 全文完 -

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