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