本文来介绍安卓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 处获得。