【代码札记】从零开始的安卓应用开发 - P2
本文来介绍安卓APP的依赖注入，以及APP中一些机制的设计。依赖注入作为一个Java后端程序员，依赖注入这个概念肯定是不陌生：Spring框架这种理科男设计出来的玩意儿，真是要命。文档里没有几句人话，全是黑话和暗语，AOP，依赖注入，IOC，OOP，动态代理，CGLIB……只有他们能懂。正常人不可能看懂。想用旧代码加上个面向切面编程——看看，全是这种黑话，切面还编程。这是人话吗？弄了半天，一头大汗，也没弄明白。网上所谓的教程，是同一帮人写的，用同一套黑话。不弄了……credit: https://disksing.com/sao-gen-gen/43PS：在中国北方，切面是面条的一种。所以用面条编程，还是挺搞笑的如果读者不了解依赖注入的话，这里简单的举一个例子。按照面向对象的原则，我们在编程的时候希望把不同功能的代码放到不同的类里面。以汽车为例，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用来移动东西。预计可能还需要一到两篇才能说完吧。
【网站札记】使用Nginx反向代理实现无代理访问谷歌API