MENU

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

May 1, 2023 • 瞎折腾

本文介绍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就好了,效果如下:

Main.png

至于实际上主菜单都要有哪些功能,我想了想,就五个:

  • 同步:将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的时候删除对应的缓存。

settings.png

同步

有了配置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 {
                // ......
            }
        }
    }
}

sync.png

打印

数据同步完成之后就可以考虑打印标签的部分了。打印部分分为两个页面:生成标签和打印。

生成标签有三种方式:

  • 随机生成:保证生成的标签在数据库中没有出现过
  • 扫描:扫描正在使用或已经打印过的标签,一般用于重复打印
  • 手动输入:我想不到什么需要手动输入的场景,但是有总比没有好

打印界面主要是和标签编码相关的,确定好类型和标签之后将标签传给打印界面。打印界面将生成打印标签的预览,以及选择打印机、纸张、重复次数等,并实际调用蓝牙联络打印机。

在打印页面返回成功后还会更新数据库,将刚刚打印的标签插入数据库,这时status为PRINTED,version为0。

label.png

label_type.png

printer_paper.png

printer.png

备份

前面对着数据库一顿操作,如果数据丢了就糟糕了。好在我们的数据库比较简单,这里我使用了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

而扫码的部分也相对简单:

  1. 调用GMS扫码服务
  2. 根据扫描到的条码,查询数据库中是否存在该标签,并且该标签是否存在对应的Entity ID
  3. 若该条码存在,则判断条码的类型,查询设置中对应的库ID和字段ID
  4. 根据目的地发出请求
  5. 请求成功后再次调用扫码服务,回到step1

这部分是一个循环,用户可以在扫码阶段执行返回操作,进而取消扫码。这时扫码部分的Activity就会顺势结束,退出快速扫描的操作。

总结

至此,关于我的第一个安卓APP就已经全部讲解完了。这一片拖得有点久,因为一直没有想明白UI部分到底应该怎么写。关于布局的代码又臭又长,而且还没什么新意,但是有些东西用起来很自然,写起来不一定自然,可是说不自然吧,又没达到复杂的程度,这种东西拿出来写显得啰嗦,不拿出来写又觉得过于省略了,实在是很难定夺。最后我觉得还是不要事无巨细的说比较好,毕竟UI做出来是要用的,不是拿来吹的。在交互上有不少细节,你不提没人知道,你加上了别人会觉得很顺畅很舒服,但是你提起来又会显得很啰嗦。

目前这个APP已经完全开源在了GitHub上。虽然对于开源软件我不提供任何质量和可用性的保障,但是这个APP毕竟是我自己用的APP,能用肯定是基本要求。

-全文完-


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

Archives QR Code
QR Code for this page
Tipping QR Code