MENU

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

April 28, 2023 • 瞎折腾

本文来介绍安卓 APP 的依赖注入,以及 APP 中一些机制的设计。

依赖注入展开目录

作为一个 Java 后端程序员,依赖注入这个概念肯定是不陌生:

Spring 框架这种理科男设计出来的玩意儿,真是要命。

文档里没有几句人话,全是黑话和暗语,AOP,依赖注入,IOC,OOP,动态代理,CGLIB…… 只有他们能懂。正常人不可能看懂。

想用旧代码加上个面向切面编程 —— 看看,全是这种黑话,切面还编程。这是人话吗?

弄了半天,一头大汗,也没弄明白。

网上所谓的教程,是同一帮人写的,用同一套黑话。

不弄了……

credit: https://disksing.com/sao-gen-gen/43

PS:在中国北方,切面是面条的一种。所以用面条编程,还是挺搞笑的

如果读者不了解依赖注入的话,这里简单的举一个例子。按照面向对象的原则,我们在编程的时候希望把不同功能的代码放到不同的类里面。以汽车为例,Car 需要有引擎,有电池,有车载电脑,有控制系统,而这些细节的实现不应该放在 Car 类里面,而是单独作为各自的类,通过接口来实现功能的调用。例如电池接口只需要供电和充电,而不必让调用者考虑这电池用的是什么材料,使用了什么技术。也许电池比较简单,那车的控制系统呢?一个控制系统会有好多输入输出,而这些输入输出也是不同的模块。这样一来,你要实例化一个汽车对象,就要先实例化它依赖的对象,而它依赖的对象又要依赖其他对象。日后如果你要对某个子系统新增一个依赖,那只能祝你好运了。而依赖注入就是来解决这个问题的。你只需要告诉依赖注入的框架如何制作这些基本的元素,而后依赖注入的框架就可以自动地根据需求来 “装配” 依赖,进而实例化对象。

依赖注入经常和反转控制(IOC)一起说,因为在以前,是我们程序员来编写代码,控制不同组件实例化的时机,进而用实例化的结果来做事。引入了依赖注入之后,实例化组件的时机不再受我们的控制,反而是我们的代码将在不确定的时机被框架调用,这就是反转控制大体思想。

当然,作为不使用依赖注入的反转控制,你也可以做一个全局共享的类,然后将所有需要的零部件放到这个类里面,通过静态成员按需使用。但这种情况下你还是需要维护一个类似于查找表的东西,我觉得这种手动的东西不如自动的框架来得优雅、简洁。

对于安卓上的依赖注入框架,我喜欢叫他 Dagger hilt,虽然 dagger 和 hilt 是两个分开的框架。

关于这些库的历史渊源我就不讲了,谷歌上都可以查到。简单地说 Dagger 是一个谷歌维护的(官方已经钦定了)安卓上的依赖注入框架。它实现了 JSR 330(Dependency Injection for Java),并且主要利用 javax.inject.Inject 注解来对非私有成员和构造器进行注入。Dagger 是在编译时期确定依赖关系的,因此在编译时 Dagger 会根据你提供的对象和需要被构造的对象生成 Factory 类的代码,从而实现依赖注入。

而 Hilt 则是一个使用了 Dagger,并意在简化 Dagger 使用的库。在安卓上,一些特定的类是不能直接进行依赖注入的,例如安卓的 Activity。要对这些类进行依赖注入,通常需要使用 Dagger 生成的工厂类。虽然 Dagger 帮我们生成了这些工厂类,但最终让安卓使用这些工厂类的代码还是要我们来写。而 Hilt 就是解决了这个问题,只需要一个注解,我们就可以直接享受到完整的依赖注入 —— 不再需要额外的配置代码了。

要开始依赖注入,我们首先需要提供一些可供注入的素材。这里为了简单考虑,所有的东西都作为单例模式注入。也就是说,这些对象一经创建,就要跟着整个 APP 的生命周期走。

  • @Module
  • @InstallIn(SingletonComponent::class)
  • object AppModule {
  • @Provides
  • @Singleton
  • fun provideAppDatabase(app: Application): AppDatabase =
  • Room.databaseBuilder(app, AppDatabase::class.java, "vazan").build()
  • @Provides
  • @Singleton
  • fun provideConfigDao(database: AppDatabase): ConfigDao = database.configDao
  • }

这里我们就提供了两个对象。一个是 AppDatabase 用来访问数据库的;另一个是从数据库里摘出来的 DAO。至于最开始的那个 Application 从哪搞,这也好办:

  • @HiltAndroidApp
  • class VazanApplication : Application()

直接创建一个就好了。当然,Provide 并不是提供对象的唯一方式。Provide 提供了一种教框架构造对象的方法。但有时候只是把参数传来传去,很没意思。例如一个 Provide 方法就是把接收到的参数传给一个构造器,这种情况下就没有必要使用 Provide 了,可以使用 Bind:

  • @Module
  • @InstallIn(SingletonComponent::class)
  • abstract class RepositoryModule {
  • @Binds
  • @Singleton
  • abstract fun bindConfigRepository(
  • repository: ConfigRepositoryRoomImpl
  • ): ConfigRepository
  • @Binds
  • @Singleton
  • abstract fun bindLabelRepository(
  • repository: LabelRepositoryRoomImpl
  • ): LabelRepository
  • @Binds
  • @Singleton
  • abstract fun bindMementoRepository(
  • repository: MementoRepositoryRetrofitImpl
  • ): MementoRepository
  • }

这里我们将之前说的 Repository 的实现 Bind 给了对应的 Repository 接口。这样 Dagger 在处理依赖时,如果有其他类用到了这些接口,它就知道该使用哪些实现了。很是方便。

如果要使用依赖注入,那就更方便了。

  • @HiltViewModel
  • class QuickScanViewModel @Inject constructor(
  • private val configRepo: ConfigRepository,
  • private val labelRepo: LabelRepository,
  • private val mementoRepository: MementoRepository
  • ) : ViewModel() {
  • // ...
  • }

这是一个 ViewModel,本质上就是一个存储了一堆状态,实现了一些业务逻辑的类。这里我们可以对构造器应用 @Inject 注解,来让 Dagger 将我们需要的依赖注入进来。但如果是 UI 怎么办呢?Activity 没法注入构造器,但它可以注入非私有字段:

  • @AndroidEntryPoint
  • class ExampleActivity : AppCompatActivity() {
  • @Inject lateinit var analytics: AnalyticsAdapter
  • }

当然,如果是注入 ViewModel 的话,还可以使用另一种方法:

  • @AndroidEntryPoint
  • class BackupActivity : VazanActivity() {
  • private val viewModel: BackupViewModel by viewModels()
  • }

这个 viewModels 方法是 androidx.activity 包里面的,严格来说不属于 Hilt,但 hilt 会为 ViewModel 生成对应的 Factory 和配置代码,免去了我们手动配置的过程,也是十分的方便。

机制设计展开目录

聊完了依赖注入,下面说说一些细节的设计吧。

设置存储展开目录

首先是设置的存储。前文中我设计了一个 Config 表来按照键值对存储设置。那么针对键应该有一个比较规范的描述。当然可以用字符串,但是这样的话,万一手滑有个 typo,那不出意外的话就是要出意外了。所以这里我使用了枚举类。除了字符串的键本身,考虑到我比较懒,所以希望直接把这个枚举类作为 UI 的设置列表来用(就是一个键名对应一个选项),所以还要加入一些对 UI 部分的支持。

目前来看,一个枚举类有三个字段:

  • 字符串形式的键
  • 是否为单行(考虑到前端的 TextField,不过目前还没有遇到多行的配置)
  • 验证器(也是给前端的,只有这个 lambda 返回 true 的时候才是有效的)

目前得配置如下:

  • enum class SettingsKey(
  • val key: String,
  • val singleLine: Boolean,
  • val validator: (String) -> Boolean
  • ) {
  • APP_LAST_PRINTER_ADDRESS("app.thermal_printer.last_address", true, { it.isNotBlank() }),
  • APP_LAST_PRINTER_PAPER("app.thermal_printer.last_paper", true, { it.toIntOrNull() != null }),
  • MEMENTO_API_KEY("memento.api_key", true, { it.isNotBlank() }),
  • // location
  • MEMENTO_LOCATION_LIBRARY_ID("memento.location.library_id", true, { it.isNotBlank() }),
  • MEMENTO_LOCATION_FIELD_ID("memento.location.field_id", true, { it.toIntOrNull() != null }),
  • // box
  • MEMENTO_BOX_LIBRARY_ID("memento.box.library_id", true, { it.isNotBlank() }),
  • MEMENTO_BOX_FIELD_ID("memento.box.field_id", true, { it.toIntOrNull() != null }),
  • MEMENTO_BOX_PARENT_LOCATION_FIELD_ID(
  • "memento.box.parent_location.field_id", true, { it.toIntOrNull() != null }),
  • MEMENTO_BOX_PARENT_BOX_FIELD_ID(
  • "memento.box.parent_box.field_id", true, { it.toIntOrNull() != null }),
  • // item
  • MEMENTO_ITEM_LIBRARY_ID("memento.item.library_id", true, { it.isNotBlank() }),
  • MEMENTO_ITEM_FIELD_ID("memento.item.field_id", true, { it.toIntOrNull() != null }),
  • MEMENTO_ITEM_PARENT_LOCATION_FIELD_ID(
  • "memento.item.parent_location.field_id", true, { it.toIntOrNull() != null }),
  • MEMENTO_ITEM_PARENT_BOX_FIELD_ID(
  • "memento.item.parent_box.field_id", true, { it.toIntOrNull() != null }),
  • // sync
  • MEMENTO_SYNC_VERSION("memento.sync.last_version", true, { it.toIntOrNull() != null }),
  • }

有了这个枚举类之后,就可以把 ConfigRepository 里面的 key: String 改成 key: SettingsKey 了。

标签编码展开目录

接下来我们说说打印相关的。虽然标签分为 DataMatrix 和 Code128 两种,并且分别给箱子和物品使用,但实际上给物品用 DataMatrix、给箱子用 Code128 也可以。DataMatrix 的好处在于自带的纠错:箱子在运输过程中难免会有磕碰,万一二维码的部分损坏了,有一点纠错还能救回来;而物品一边都比较小,相比之下 Code128 更节省空间,当然你要能贴的下 DataMatrix 那也无所谓。

为了防止条码损坏读不出来,我还在标签上连对应的文字版编号也一起打印了出来,但这时候就有一个问题:0O1Il 你能分清几个?如果只考虑大写字母和数字,那 0O1I 你又能分清楚几个?为了避免标签过于依赖字体,同时尽可能避免混淆,我决定使用 23456789ABCDEFGHJKLMNPQRSTUVWXYZ 这几个字符来编码。好巧不巧,他们正好 32 个字符,我设计一个标签有 10 个字符,第一个用来区分类别(B 和 I 分别表示 Box 和 Item),剩下 9 个用于编码序号,这样一来 9 个字符可以编码 35184372088832 个序号(3.5 万亿个,从 0 到 32 的 9 次方)。

编码的过程非常简单:无非就是把数字从 10 进制转换成 32 进制。但为了方便解析和生成,我们采用小端序来表示。

以 12345678 举例:8 是这个数字中最小的位数,而 1 是最大的。从字符串的角度来看,1 的索引是 0,是最小的;8 的索引是最大的。这样一来就成为的低位在高地址,高位在低地址。对于计算机来说就是这样的:87654321。对于人类来说怎么看都有点别扭,但是对于计算机来说是刚刚好:通过求余数,我们先产生低位,后产生高位。在解析的时候,我们先计算出低阶的,然后再计算出高阶的。

关于算法就不多说了,进制转换应该没什么难的,余数和除法、乘法和累加的事情。

但是直接用这个字符集会比较丑。以 0 为例,它编码出来是 222222222,因为字符 2 的索引是 0。这种看起来就很奇怪。如果我们随机打乱一下,还是会很奇怪:AAAAAAAAA。总之这种连续的字符就很奇怪。

好在没人规定我们必须用同一套字符集。由于我们的编码的位数是固定的,因此我们可以针对每一个位置设定一个字符集。例如第一个字符采用 P4RQZ8MYLCBSKXWUJ35F6VDG79AN2EHT 这个顺序,第二个字符采用 TYZK2CG9AWD7RNSPQHEJX36M4FLB58UV 这个顺序,以此类推。这样虽然每一位算出来都是 0,但第 0 号索引的字符在每一位上都各不相同,这样算出来的编码显得很有随机性,即便是连续编码也不太容易发现规律。

那么我们应该怎么构建这个字符集呢?

  • fun generateRandomDigits(
  • count: Int, seed: Long,
  • chars: List<Char> = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ".toList()
  • ): List<String> {
  • // make sure don't overflow
  • log(Long.MAX_VALUE.toDouble(), chars.size.toDouble()).toInt().let {
  • require(count <= it) { "Too many digits: Max $it" }
  • }
  • val random = Random(seed)
  • while (true) {
  • val digits = Array(count) {
  • chars.shuffled(Random(random.nextLong())).toCharArray()
  • }
  • val allDistinct = digits.indices.map { i ->
  • digits.map { it[i] }.distinct().size
  • }.all { it == count }
  • if (!allDistinct) continue
  • return digits.map { it.concatToString() }
  • }
  • }

通过随机产生一些字符序列,我们来判断是否每一位上都具有不同的字符。由于我们的字符有 32 个,而位置只有 9 个,根据抽屉原理,肯定存在不重复的组合。

产生标签展开目录

说完了编码,下面我们说说怎么产生标签。

在 Java 中我们可以使用 BufferedImage 来绘制图片,到了安卓上则变成了 Bitmap。这二者有什么区别我暂且没有详细了解,不过后者的性能据说要比 BufferedImage 要好,我不知道真的假的,反正我没什么感觉。

关于条码部分,我使用了 zxing 这个库来生成。这个库有点年头了,但好在条码的标准也好久没有更新换代了,暂且还是可以使用的:

  • private fun dataMatrix(width: Int, height: Int, content: String): BitMatrix =
  • DataMatrixWriter().encode(
  • content, BarcodeFormat.DATA_MATRIX, width, height,
  • // force square
  • mapOf(EncodeHintType.DATA_MATRIX_SHAPE to SymbolShapeHint.FORCE_SQUARE)
  • )
  • private fun code128(width: Int, height: Int, content: String): BitMatrix =
  • Code128Writer().encode(content, BarcodeFormat.CODE_128, width, height)
  • private fun BitMatrix.toBitMap(): Bitmap {
  • val image = Bitmap.createBitmap(this.width, this.height, Bitmap.Config.ARGB_8888)
  • for (y in 0 until this.height) {
  • for (x in 0 until this.width) {
  • if (this[x, y])
  • image.setPixel(x, y, Color.BLACK)
  • else
  • image.setPixel(x, y, Color.WHITE)
  • }
  • }
  • return image
  • }

这三套函数一个组合拳下来,就能产生给定大小、给定内容的条码图片。注意这里我使用了 ARGB_8888,这块和 Java 的 BufferedImage 不一样,必须要使用 ARGB_8888 才能正确显示和处理。我最开始想用 ALPHA_8,但后来发现放在 UI 上预览的时候是空的,百思不得奇怪。最后无意间才发现是这个的问题。

有了条码,接下来也得把文字变成图片:

  • private fun textToBitmap(text: String, fontSize: Double): Bitmap {
  • val paint = Paint().apply {
  • textSize = fontSize.toFloat()
  • color = Color.BLACK
  • typeface = Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL)
  • textAlign = Paint.Align.LEFT
  • }
  • val baseline: Float = -paint.ascent() // ascent() is negative
  • val width = (paint.measureText(text) + 0.5f).toInt() // round
  • val height = (baseline + paint.descent() + 0.5f).toInt()
  • val image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  • val canvas = Canvas(image)
  • canvas.drawText(text, 0f, baseline, paint)
  • return image
  • }

这里我们使用了 Monospace 字体,这个字体能够保证所有字符都是等宽的。我们首先根据 Paint 对象测量一段文字对应的长和宽,然后创建对应大小的画布,最后在上面绘制文字。

最后就是把二维码和文字组合在一起了:

  • private fun generateImage(
  • width: Int, height: Int, barcode: Bitmap, text: Bitmap,
  • xOffset: Float, yOffset: Float, ySpacing: Float
  • ): Bitmap {
  • val image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
  • val canvas = Canvas(image)
  • // white background
  • canvas.drawRect(0f, 0f, image.width.toFloat(), image.height.toFloat(),
  • Paint().apply { color = Color.WHITE })
  • // barcode
  • canvas.drawBitmap(barcode, (image.width - barcode.width) / 2.0f + xOffset, yOffset, null)
  • // text
  • canvas.drawBitmap(
  • text,
  • (image.width - text.width) / 2.0f + xOffset,
  • yOffset + barcode.height + ySpacing,
  • null
  • )
  • return image
  • }

这里的长和宽是整个图片的大小。后面的 xOffset 决定了整个图像和水平居中的位移,如果是 0 的画,图像正好是水平居中的;如果需要偏移的话可以用这个调整。这个参数是考虑到打印机在打印的时候,由于打印头位置的偏差,其实左侧是存在一定留白的,如果这个时候图像还要居中,那打印出来就偏右了。

再后面的 yOffset 则决定了条码的顶部和图像顶部有多少留白;ySpacing 则决定了文字的顶部和条码的底部中间有多少留白。注意哦,这些参数不一定非得是正的。如果你用的字体本身就有比较大的留白,而标签又要很紧凑,你可以用负数来调整取消字体本身的留白。

这还没完,有时候打印纸不一定是方向正确的,例如 80x60 毫米的打印纸,有竖着的,也有横着的。对于横着的,我们还得旋转一下:

  • private fun Bitmap.rotate(angle: Float): Bitmap {
  • val matrix = Matrix()
  • matrix.postRotate(angle)
  • return Bitmap.createBitmap(
  • this, 0, 0, width, height, matrix, true
  • )
  • }

标签生成算是搞好了。但是打印呢?打印机使用 TPSL 语言来传输打印数据。其中图片数据是按照比特传输的。也就是说一个字节就表示了 8 和横着的点。我们可以就 Bitmap 转换成打印数据写点代码:

  • private fun Bitmap.getGrayPixel(x: Int, y: Int): Int {
  • val c = this.getPixel(x, y)
  • val r = (c shr 16) and 0xFF
  • val g = (c shr 8) and 0xFF
  • val b = c and 0xFF
  • return (0.3 * r + 0.59 * g + 0.11 * b).roundToInt()
  • }
  • fun Bitmap.toDataArray(): Array<IntArray> =
  • (0 until this.height).map { y ->
  • (0 until this.width).map { x ->
  • this.getGrayPixel(x, y)
  • }.toIntArray()
  • }.toTypedArray()
  • fun Array<BooleanArray>.collapse(): ByteArray {
  • val width = (this[0].size / 8.0).roundToInt()
  • val result = ByteArray(width * this.size)
  • for (y in this.indices) {
  • val row = this[y]
  • for (x in row.indices) {
  • val wordIndex = x / 8
  • val bitIndex = 7 - (x % 8)
  • var w = result[wordIndex + y * width].toInt()
  • val mask = 1 shl bitIndex
  • w = if (row[x]) { // set the bit to 0 for black
  • w and mask.inv()
  • } else {
  • w or mask
  • }
  • result[wordIndex + y * width] = w.toByte()
  • }
  • }
  • return result
  • }

这套代码其实是从 Java 那边复制过来的。在此之前我在尝试用这个打印机打印图片。但经过一番尝试,这个打印机对于打印头的温度控制的不是太好,并且在位置控制方面也比较松动。总第来说,这个标签打印机就不是设计来给你打图片的。合情合理,不是吗?

所以这部分代码可能会有些啰嗦。首先是从 Bitmap 中获取灰度值,因为输入可能是彩色。这个 toDataArray() 就是把 Bitmap 转换成值为 0 到 255 的灰度数据。获取灰度值之后原本可以进行一些处理,比如调整曲线,或者应用一个抖动,但这里我们的图像本身就是黑白的了,所以就不需要额外处理了。我们需要在使用的时候做一个简单的 map,将 0 转换成 true,将 255 转换成 false。而后的 collapse 则是将这些黑白数据转换成 TPSL 格式的图像数据。

纸张尺寸展开目录

前面铺垫的差不多了,再打印之前就剩下最后一件事了:决定纸张尺寸。对于专门的标签打印软件来说,他们有专门的 UI 来让用户针对不同纸张来设计标签。但我不想那么麻烦,更何况我这布局标签的代码都写出来了。

尽管如此,不同的纸张还是要有不同的考量。这里我将字符串形式的标签编码作为输入,根据纸张的大小来决定用什么条码。对于比较大的 80x60 的条码,我们可以用 DataMatrix;对于 70x30 这种长条形状的,我们可以用 Code128 这种传统条码。同样的,为了方便前端列出所有可能的纸张,这里依旧使用了枚举类:

  • enum class PaperSize(
  • val displayName: String,
  • val gap: Int,
  • val generatePreview: (String) -> Bitmap,
  • val generatePrintData: (String, Int) -> ByteArray
  • )

首先是 displayName,这个是在前端中为用户展示的纸张规格,后面的 gap 表示纸的间隙,一般是 2mm。后面有两个 lambda,第一个 generatePreview 是给定标签编码,产生一个预览图片;第二个则是给定一个标签编码和重复打印的次数,直接生成字节数组格式的打印指令。我们以 80x60 这种横板的纸张为例:

  • PAPER_80_60_2(
  • displayName = "80mm x 60mm", gap = 2,
  • generatePreview = { PrintUtils.generate80By60(it) },
  • generatePrintData = { str, repeat ->
  • val imageData = PrintUtils.generate80By60(str).toDataArray()
  • "SIZE 80 mm,60 mm\r\n".encodeToByteArray() +
  • "GAP 2 mm,0 mm\r\n".encodeToByteArray() +
  • "DENSITY 2\r\n".encodeToByteArray() +
  • "SPEED 1.5\r\n".encodeToByteArray() +
  • "CLS\r\n".encodeToByteArray() +
  • "BITMAP 0,0,${imageData[0].size / 8},${imageData.size},0,".encodeToByteArray() +
  • imageData.map { row -> row.map { it == 0 }.toBooleanArray() }
  • .toTypedArray().collapse() + "\r\n".encodeToByteArray() +
  • "PRINT ${repeat}\r\n".encodeToByteArray()
  • }
  • ),

其中实际产生图片的部分是:

  • fun generate80By60(str: String): Bitmap {
  • val barcode = dataMatrix(320, 320, str).toBitMap()
  • val text = textToBitmap(str, 64.0)
  • val image = generateImage(480, 640, barcode, text, 0f, 50f, 40f)
  • // now we get 640 by 480 -> 80 by 60 under 203 dpi
  • return image.rotate(-90f)
  • }

因为纸张的大小都是固定的,所以像是条码的尺寸、留白的大小这些也都可以直接写死。

总结展开目录

至此,所有非 UI 的部分都已经讲解完毕。这时脑袋尖的读者可能就问了,你这蓝牙打印的部分呢?你不会把蓝牙打印的部分也放到 UI 里面去了吧?不会吧不会吧不会吧

这脑袋尖的读者多少有些烦人了(bushi

虽然使用蓝牙调用打印机不算是 UI 的一部分,但是蓝牙本身的操作就是依赖于 Context 的。例如一上来你需要调用 getSystemService(Context.BLUETOOTH_SERVICE),光是这一点就让你没办法了。当然,你可以通过依赖注入拿到一个 Application,也就是 Context,但是我总觉得用这种类似于 “飞线” 的方式滥用 Context,反而不利于整理架构。因此我的做法是创建 Activity 的时候初始化蓝牙,然后在 ViewModel 中调用蓝牙打印。

关于 UI 的部分我也分了几块。首先是 Material 3,这里面涉及到一些配色之类的问题,尤其是安卓 12 开始支持系统级的动态配色了,这极大的缓解了我缺乏艺术细胞的问题。然后是分组件介绍,先是主菜单、设置页面和备份页面;然后是 Memento 同步,接着是标签打印,最后是 Quick Scan 用来移动东西。预计可能还需要一到两篇才能说完吧。


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

Archives QR Code
QR Code for this page
Tipping QR Code