【代码札记】在安卓中实现骑行时多人聊天
上一篇文章介绍了我这个语音聊天的协议设计。本文将介绍我是用安卓实现APP的过程。前言由于我不是专业的安卓开发，所以本文写的一些内容可能不是最佳实践，甚至可能与之背道而驰，只是姑且能用的草台班子罢了。如果你发现了这样的问题，欢迎在评论区中友善地提出来，我会十分感谢你的友好纠正。此外本文的写作意图不是从头到尾的记录或手把手的教学，而是记录我在开发过程中遇到的问题，以供未来我或其他人参考之用。如果有部分描述得不够详细，属于正常范畴。第一个Activity新建项目后会自带一个主Activity，作为一个手生的懒人，我自然想着页面总数越少越好、每个页面越简单越好。所以没有那些花哨的广告和宣传，还有什么存钱贷款之类的功能，我的APP就要做一个小而美。在程序成型之前，这个主页面被用来开发和测试新功能。在开发时我并不知道这个APP到底能不能成功，所以在主界面开发基本功能，测得差不多了再把他们打包封装成其他Activity。程序开发的差不多之后，主页面就作为设置页面，用来启动和关闭相应的功能。而另一个详情页面则用于展示广播中的其他节点和调整不同节点的音量。整个app一共两个Activity和一个前台服务，整体APK打包之后不到5MB。朋友们，这才是小而美啊！作为设置的页面，当然越简单越好。因为这个页面要管理服务，所以需要让用户进行一些配置：用户的昵称，用来向其他节点宣告加密用的密码，用来防止未经授权的监听组播信息，用于决定最终组播使用的ip和端口号关于组播IP，上一篇文章已经决定了，我们要使用239.255.0.0/16。但是用户不一定记得住，说实话我也记不住，这个是从上一篇文章复制粘贴来的。怎么办呢？训练用户背下来就好了。我们当然可以把IP地址分成四段，前两段是固定的label，后两个是文本框，但这样一来我就需要两个文本输入来决定IP，太麻烦了。如果往根上说，IPv4地址就是一个32bit的数据，那对应的第16位就是我们这里要由用户决定的。16bit能搞一个什么数据类型呢？当然是short了。所以我们就搞一个文本框，让用户输入一个0到65535之间的数字，我们用这个数字和前面的239.255一拼接，这不就决定好ip了？这个懒偷得很妙，我是说，这个设计很简洁、巧妙。当然，如果你再想想看，端口号的取值范围也是0到65535，两个16bit拼在一起就是一个int了，不如让用户输入一个整数，这样一来我们一个文本框就解决了两个参数，岂不是更妙？我确实考虑过这种设计，抛开数值过大，会给用户交互带来困扰不说，安卓本身也是个unix系统，所以1024以下的端口只有系统能够占用，我又不是拼多多，作为一个普通程序不能也不应该去使用root权限，所以端口号的范围就限制在了1024以上的端口。再说说看用户交互的问题。你让用户随机挑一个0到65535之间的数字，我觉得这个分布还是挺随机的；但如果你让用户在0到42亿之间随便挑一个数，你能指望用户给你输入10位的数字？更何况用户要避免这个数字被解析出小于1024的端口号，简直是难上加难。如果这样设计的话，恐怕用户的输入都要挤在6位或8位数字以内，而我的本意是希望所有的地址和端口都能够平等地被用户随机选择。所以很遗憾，懒偷得多了就要变成烂活，我选择单独搞一个输入端口号的文本框。密码和用户昵称这个就彻底没办法偷懒了，只能老老实实地弄两个文本框。最后再搞三个按钮，分别用于启停服务和切换到详情页。是的，启动和停止我使用了两个按钮。我知道可以使用bind拿到服务，然后看启停状态，把启动和停止做到一个按钮上。但这不是懒嘛。换个角度想，我这种设计避免了页面整体被recompose的次数，四舍五入算省电设计。持久化保存配置当我测试完配置页面的逻辑之后，我发现了一个问题：我每次关掉app重新打开，就得把昵称、密码那些东西重新填写一遍，我要是用户，这个app肯定活不到它第三次启动。为了解决这个问题，我搜索了一下，安卓说要用preferences这个东西。简单地说，它提供了一个十分简单但又够用的datastore，允许程序员快速存储配置。关于它的使用方法我就不多说了，关键词都给你列出来了，想知道就自己去谷歌啊。在我的APP中，我使用了一种简单但又没那么粗暴的方式来使用它：通过订阅flow来实时更新对应的文本框，在启动服务时异步地将新配置保存回去。最开始我直接把文本框的内容和配置项绑定到了一起，导致文本项中的一个变化要传递到它的值上，然后这个值再被保存到配置项里，然后订阅的协程收到变化后再把变化的值赋给文本框，这一个流程下来会增大处理用户输入的延迟，并且这其中好多都是无用功。所以最终没有使用这种方案。前台服务服务保活可能是所有开发者最头疼的事情了。因为不同的厂家会在自己的ROM里实现不同的后台管理。有些厂商为了满足自己“省电”的宣传，可能杀后台会杀的比较狠，即便用户允许程序在后台运行，给app开了绿灯，后台服务可能还是难逃一死。前台服务比后台强点，但面对国产ROM还是无法保证效果。但还是那句话，我又不是拼多多，我只能尽力而为。用户选择软件，和软件选择用户，这都是对应的。前台服务是普通服务的一种特殊形式。具体表现就是把一个服务和一个通知绑定在一起，这样就能够尽最大努力避免服务被杀掉。像是微信语音聊天等功能都用到了前台服务，避免你和朋友聊到一半系统突然把服务杀了。为了贯彻小而美的宗旨，这个app只有一个前台服务，它负责四件事：录音、广播数据接收并处理其他人的数据将其他人的数据混音，输出给系统其他需要定期执行的杂事例如每隔一段时间就删掉不活跃的节点以上这四点各自对应一个线程。但在启动线程之前还要做一些准备工作。这就不得不吐槽一下服务的生命周期了。目前我用到的函数重载有：onCreate、onBind、onStartCommand和onDestroy，他们分别在服务被创建、用户调用startForegroundService、调用bindService和调用stopService时被调用。那么服务什么时候被创建呢？当用户调用startForegroundService和bindService的时候会被创建，同时系统可能也会尝试创建服务。其中后者是你无法把控的，我曾经遇到过一个很罕见的由此产生的崩溃。也就是说，这个onCreate其实是允许调用系统服务的构造函数，因为在真正的构造函数中你可能没办法调用系统服务，因为他们还没有被正确初始化。那onStartCommand呢？听起来是用户明确调用startForegroundService时调用的，因为他会把用户传进来的intent传给我们的onStartCommand。但实际上系统也会调用这个函数，取决于你的返回值。因为前台服务并不保证一定能存活，因此当系统资源严重不足的时候，系统就会开始杀前台服务，但当资源恢复之后，系统会根据程序员的意图，尝试恢复被杀掉的前台服务，但这一点不能保证。一开始当我返回STICKY的时候，我希望系统能够恢复我的服务，但没有经过测试。机缘巧合之下，这确实发生了，但每次都会引发app崩溃。后来发现我这个onStartCommand实现要求intent不为空，因为要从中获取广播参数。而START_STICKY只是保证系统会重新调用onStartCommand来重启被杀掉的服务，但不会保留参数。要保留参数的话，我需要START_REDELIVER_INTENT。这样系统就会保留最后一次传给这个服务的intent，在重启时作为调用onStartCommand的参数。当然，安卓文档并没有保证系统一定会重启服务。我在朋友的手机上观测到，小米系统不仅当着我的面杀掉了前台服务，还不给我重启。那onBind呢？在启动服务时，我们创建一个intent，其中包含我们希望启动的服务的Class实例，随后安卓系统会代替我们创建并启动。但问题来了：安卓系统并不会返回给我们一个服务的示例，因此我们没办法与之进行通信。当然，我们可以通过网络或广播进行通讯，但最简单的当然还是直接拿一个对象去调用他的成员方法。这个时候我们就可以通过bingService和onBind来实现。当我们调用bingService时，就需要服务返回一个实现了IBind的对象，这个对象会作为bingService的返回值交给调用者。如果我们把服务本身（也就是服务的this）包装到这个返回值里，不就可以拿到服务对象并和他交互了吗？确实如此，只不过别忘了这还有一个副作用：如果服务没有被创建，那在执行onBind之前，系统会“贴心”地帮我们创建服务。因此在设计时没办法简单的通过bind有没有连接上来检测服务的运行状态。最后就是onDestroy了。我原本期待有一个onStop之类的重载，这样当用户决定停止服务时可以停止，然后接下来可以选择再次start，或者调用destroy被回收——这个onDestroy应该被用于回收资源才对。但很遗憾，这个onDestroy确实是服务被停止时才会调用的，你需要在这个函数里停止服务并释放资源。安卓系统保证在进行了这个调用后，不会再有其他针对这个service示例的调用了。也就是说服务被停止后，它就会被彻底销毁，不会占用任何资源，直到重启。这个生命周期一开始觉得有些反人类，写代码的时候逐渐有些理解了，但时代如今我还是坚持应该加一个onStop重载，将停止服务和销毁服务分开。系统可以决定停止后立刻销毁，也可以决定等待一段时间再销毁，如果这期间服务被重新启动了，那不就免去了重新初始化资源的麻烦了吗？不过好在这种设计没有给我带来太大的困扰，所以我也就不多批评了。蓝牙音频在前台服务中我提到它的职责之一就是要不停的录音，然后把音频数据广播出去。但蓝牙拼音这块真的令我大费周章。没想到这么多年过去了，你说PC上JVM的蓝牙没有文档，支持不行，我念你不同的系统API设计不同，难以兼容，就不说什么了。怎么到了安卓上还是一个德行？一开始我注意到，即便是我连接了蓝牙耳机，程序还是从手机自带的麦克风获取数据。我查阅了各种方法，Google给我推了一个BLE Audio的，但很明显，现在几乎没有耳机使用这种协议。我又查了查，最后发现蓝牙耳机所使用的协议无外乎两种：A2DP和SCO。前者对应音乐，后者对应语音，有时候也叫通话。如果你曾经在电脑上连接过蓝牙耳机，比如Windows上，你会注意到Windows提示“已连接的音乐，语音”，同时你的系统里多了两个音频输出，一个是立体声输出，一个是hands free免提输出。这个高音质的立体声输出就是A2DP协议，它提供了一种高带宽的单向通信，允许宿主向蓝牙耳机推送高质量、数据量大的音频数据。而后者的hands free则是用于通话的，它的音频质量不高，但提供了双向通信，允许宿主从蓝牙设备上获取麦克风输入。如果用过腾讯会议，并且腾讯会议恰巧识别到了这个hands free之后，我相信你会认识到这两种通道的不同的——如果你的电脑同时通过立体声输出和hands free输出向蓝牙耳机输出音频，那么hands free的音频流就会遮盖住立体声的流。具体到我们这个APP来说，我们当然希望使用SCO来建立双向的音频流，不然骑车时我们的手机都放在离嘴比较远的地方，那恐怕被人听到的就只有呼啸的风声了。但通过AudioManager.startBluetoothSco()之后，我发现我必须使用USAGE_VOICE_COMMUNICATION才能在蓝牙耳机中听到我的APP的输出。如果我使用常规的USAGE_MEDIA，声音就会非常小，几乎为静音状态。一开始我以为和电脑上是一样的，我APP开启的SCO流盖过了A2DP流，但后来我意识到蓝牙耳机不能同时开这两个流，因此我需要想办法让系统把媒体流也输出到SCO上。经过各种搜索，最终我发现系统原本就是这样子的，但因为开始SCO模式后，系统自动进入了通话模式，因此会把其他音频的音量压得非常低。放音乐的话能够勉强听到，而语音通话则完全听不到，让我误以为是音频流被SCO给顶掉了。解决方法也非常简单，在创建了SCO流之后使用audioManager.mode = AudioManager.MODE_NORMAL把模式改回普通就好了。但问题还没结束。使用USAGE_VOICE_COMMUNICATION的时候，系统默认会把通话流输出到听筒。如果没有蓝牙耳机的话，在骑车的时候没人想举着手机像打电话一样听人聊天。但我又不想额外研究外放，所以我搞了一个自动刷新音频IO的机制。如果存在SCO设备并且SCO已经启用，那么就自动使用通话流，否则使用媒体流。但问题来了：检测SCO状态是通过回调来做的，写音频流是在另一个线程，而初始化音频IO是在服务被调用onStartCommand时进行的。我放佛能听见有人在喊：锁！加锁啊！锁当然是个好东西，无论是监视器锁还是可重入锁，但我总觉得锁这个东西挺费性能的，虽然客观上说有没有锁其实性能影响不大，但我喜欢小而美，所以能不用锁就不用锁。主要还是加了锁就意味着程序员需要额外遵守写代码的规则（在使用资源时记得加锁）。这没有什么难的，但它开启了一种可能性——如果程序员忘记加锁，程序就会表现得不正常，这要是调试起来就会非常痛苦。当然，你可以搞一个函数来访问这个资源，但很丑陋，不是吗？我们的问题在于，我们需要在SCO断开的时候，重新创建一个音频IO，让他使用媒体流而非通话流。最直白的设计无外乎如此：加锁{ 关闭原来的音频流； 创建新的音频流； 启动新的音频流； }这样一来我们可以保证我们在替换变量时，没有人能够访问我们尚未初始化完成的音频流。如果要提高性能的话，我们可以使用ReentrantLock。当其他线程读取音频流时加锁，读出数据之后就释放锁。如果我们获取到了锁，在替换时读取的线程将被阻塞，等我们替换完成之后它读到的就是新的音频流了。完美。但，读音频流的部分已经用try...catch包裹了一层了，现在要用synchronized再包一层，难看，太难看了。不够优雅。那怎么办呢？想想看并发三要素：原子性，可见性和有序性。我们需要原子性吗？很显然需要，我们当然不想让其他线程获取到我们还没有准备完毕的音频IO；那我们需要可见性吗？也需要，因为我们要让其他线程看到新的音频流，因为旧的已经被关闭了；最后，我们需要有序性吗？好像。。。没那么需要？我们这里的重点在于“替换”，我们需要用新的替换旧的，这个好像没什么顺序可言。基于这一点观察，我们可以使用volatile关键字来实现这个功能。volatile能够保证内存可见性，也就是说，A线程修改了变量，那么B线程就能立刻看到这种修改。原子性则由赋值操作来保证——很显然变量赋值是原子的，不然这代码没法写了：你给一个变量赋值为8，如果这个操作不是原子的，那下一条只能只能看到这个值的低几位被写入了，那还玩什么？最后，有序性能够通过volatile带来的内存屏障来获得一定程度的保证。前面说了，我们需要创建并初始化一个新的音频流，用来替换旧的，同时还要关闭旧的音频流释放资源。但如果新的音频流还没来得及初始化就被调用了，或者旧的音频流在关闭后仍被调用，那就坏事了。好在volatile的内存屏障能够保证读写之前的副作用一定发生在读写之前，而读写之后的副作用一定能发生在读写之后。这听起来像是废话，但考虑指令重排序的话就不是了。如果读者不了解指令重排序和内存屏障，还请移步谷歌自行搜索。简单地说，如果我们在赋值发生前就创建好音频流并初始化，那么赋值发生时，volatile保证音频流一定是初始化的（否则JVM可能会把初始化的指令重新排序到赋值之后）；而赋值发生之后，volatile保证其他线程获取到的音频流就已经是新的音频流了。这时再关闭旧的音频流，能够保证后续进程不会访问到已经被关闭的旧音频流。当然，有一个边界情况是一个线程已经拿到了旧的音频流，这时候我们替换并关闭了旧的流，那么读数据的线程就会出问题。这里我查询了音频流的实现，当这种错误发生时，错误是通过返回值返回的，因此不会出现异常。正常情况下返回值会给出实际读写的数据量，出现错误后将会返回小于0的错误代码，这种情况会被程序忽略。问题不大。最终设计如下：获取旧的音频流； 创建并初始化新的音频流； 将新的音频流赋值给volatile变量； 关闭旧的音频流；详情页详情页就没有太多细节可以说。这个页面的主要功能就是绑定服务并获取一些状态，比如当前的节点列表。这个页面还允许用户调整自己的广播音量，同时还可以分别调整每一个节点的音量。为了防止骑行时误触，所有音量条默认都是锁定状态，需要用户先点击解锁按钮，然后才能调整。总结程序设计无非两点：偷懒和嘴硬。除非有利可图，不然这两点难以避免。截至目前，我的APP已经开源到了GitHub，如果没有意外的话我会持续更新。不过还是那句话，我这个代码是开源的，有需求可以提issue，但我鼓励你自己实现并发起pull request。我没有收你一分钱，所以我没有义务实现任何人的愿望。-全文完-
【代码札记】从零开始的安卓应用开发 - P4
哈哈，没想到吧，竟然还有续集！其实我也没想到。本文介绍我如何为我的安卓APP追加扫码功能。背景本来上一篇就完结了，APP也写完了，目前也没有需要追加的功能了。但是在实际使用中发现，使用Google的条码扫描器，经常会出现Google Play Service自废武功的情况。为了解决这个情况我真是新买了一台三星S23+，但情况还是没有改善。这部分的故事是这样的。我原本使用的是红米K40 Pro，使用了MIUI EU版本的系统，这个是一群票友基于中国版MIUI固件，缝合了谷歌全家桶，并删减了一些臃肿的小米框架得到的第三方ROM。在此基础上我还进行了Root，并安装了一些Magisk模块，例如存储空间隔离等。总之是一个非常客制化的一个环境。在进行长期测试时我发现Google的条码扫描服务经常在一段时间之后变得不可用。具体表现就是抛出com.google.mlkit.common.MlKitException: Failed to scan code这样的异常。经过一番搜索，我发现清空Google Play Service的数据能够缓解这个问题——因为扫描服务是按需下载的，清空数据之后要重新下载扫描服务，当然就可以恢复正常。但是过一段时间（一天）又会抛出异常，还得再清空，就很烦。于是我开始搜索详细原因。在Google官方的一个Issue tracker上我看到有一个人说关于Google Play Service版本的问题，我查了一下，我这个好像不是最新版，于是就想办法升级。但是升级过程中我发现Play商店总是失败——并不是那么明显的失败，而是点了升级按钮之后，短暂读条，然后立刻又变回去了。我尝试过单独下载最新版的Google Play Service的APK，手动安装，但是系统提示我签名不一样。我寻思，这不对啊，都是谷歌的APK，怎么会签名不对呢？于是我又回到Google Play商店开始点升级按钮。按照以往的经验，有时候Play商店会犯傻，因为在国内需要使用代理访问，所以Play商店会误判无网络，从而导致安装失败。于是我开始不停的点升级按钮，然后到了某一刻，开始读条之后没有退出，我以为是Play商店想明白了。过了一段时间（指看了一会儿YouTube），我也没有检查商店页面有没有升级好（默认情况下在Play商店是搜不到Google Play Service的），就直接回到我的APP调用了扫码服务。结果这一下手机就卡死了，因为我用的是全面屏手势导航，所以界面也不响应我的手势。我想，那重启一下应该就没问题了吧。结果这一个电源键按下去，它就再也不启动了。具体表现是就是开始会过logo动画，过完动画之后就卡死在这里了，不进入桌面。但是指纹传感器会有震动反馈。然而屏幕不响应触控操作，重启之后需要键盘才能解锁。理论上我可以尝试插入一个usb键盘，但我觉得希望不大。所以就直接刷机了。四清了四五次，这手机才重新见到桌面。关于这个过程，我猜测有如下几种可能：票友缝合的谷歌框架有问题，因为刷机之后我查询Google Play Service的安装时间，它反馈是2009年，相比之下，三星系统的安装时间显示的是2023年；与此同时，我从网络上下载的APK提示签名不符，这其中肯定是有修改系统在Root之后影响了Play商店的更新，导致更新时把系统搞坏了我安装的Magisk模块干扰了Play商店的更新，但具体是什么模块不清楚总之我决定不再使用票友制作的ROM了。一方面是小米的系统太臃肿了，基于中国版ROM，但又缺乏中国版ROM的特性（例如公交卡、钱包等），有Google框架，但没有认证级别（原版MIUI据说有，但是很明显，经过EU修改之后就没了）。总之就是不如原生的体验。所以左思右想，换了三星S23+，关于这个设备，在我使用一段时间之后将会写文章和大家分享感受。API设计总之，既然没有办法绕开这个Bug，就只能自己实现扫码功能了。在开始之前，我们先看看谷歌这个库的API设计：val scanner = GmsBarcodeScanning.getClient( this, GmsBarcodeScannerOptions.Builder() .setBarcodeFormats( Barcode.FORMAT_DATA_MATRIX, Barcode.FORMAT_CODE_128 ).build() ) scanner.startScan() .addOnSuccessListener { barcode -> // use the barcode } .addOnCanceledListener { showToast("Scan canceled") } .addOnFailureListener { e -> showToast("Scan failed: ${e.message}") }首先需要创建一个Client，这个Client在创建的时机上并没有要求，你可以随用随创建，也可以在一开始创建，创建完保存在ViewModel里供以后调用。创建完Client之后就可以发起扫码请求了。发起请求后需要添加三个回调。成功的回调将会在扫码成功后调用，参数是扫出来的二维码。取消则是用户通过返回按钮退出了扫码页面，而失败则很明显，是捕获异常用的。很明显，Google应该是用了什么高深的生命周期之类的东西。对于一般的拉起Activity并获取返回数据的话，需要使用registerForActivityResult并配合ActivityResultContracts.StartActivityForResult()来使用。同时安卓对于注册回调的时机也有要求，并不能做到像Google那样随用随创建。所以没办法只简单封装registerForActivityResult来进行调用。UI总之，在想明白API怎么设计之前，不如先从简单一些的UI下手。扫码界面的UI非常简单：最底层是相机预览上层是两个按钮，一个控制闪光灯，一个控制缩放而谷歌官方推荐使用CameraX这个库配合ML Kit使用，可以避免许多样板代码。CameraX主要提供两种控制方式，一种是CameraController，一种是CameraProvider。前者旨在使用少量代码就能提供完整的相机功能；而后者旨在提供较多的控制选项。很明显，前者更适合扫码——你总不会在预期让用户在扫码的时候手动调整对焦和曝光吧？CameraController提供了一些自动控制的功能，例如在Preview上点击以进行对焦，还有双指缩放来控制相机的缩放等。配合PreviewView和MlKitAnalyzer，我们可以完成预览和扫码这个需求。UI部分还是和前几篇一样使用Compose。最底层使用了AndroidView来提供不兼容Compose的PreviewView的对象，随后使用Column和Row在屏幕下方绘制按钮，用来控制闪关灯和缩放。在创建UI之前，我们需要先处理好相机的部分： // setup camera controller val cameraController = LifecycleCameraController(this) cameraController.bindToLifecycle(this) cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA cameraController.isTapToFocusEnabled = true cameraController.isPinchToZoomEnabled = true // setup camera preview val previewView = PreviewView(this) previewView.controller = cameraController previewView.scaleType = PreviewView.ScaleType.FILL_CENTER这里我们需要在Compose外面创建好View对象，因为这些对象并不需要每次都重新创建。首先是CameraController，这里允许点击对焦和双指缩放，并要求使用后置摄像头进行扫码。然后创建一个PreviewView来展示相机的预览。这里的缩放默认为FILL_CENTER，也就是让相机画面填满屏幕并居中。这样一来相机画面的周围部分就会被裁剪，如果希望显示完整，那么可以之后通过按钮切换。这里我们刚刚设置好了预览的部分，接下来说说图像分析，也就是扫码的部分： // setup the scanner cameraController.setImageAnalysisAnalyzer( ContextCompat.getMainExecutor(this), MlKitAnalyzer( listOf(barcodeScanner), COORDINATE_SYSTEM_VIEW_REFERENCED, ContextCompat.getMainExecutor(this) ) { result -> val centerX = previewView.width / 2 val centerY = previewView.height / 2 // select the center one val list = result.getValue(barcodeScanner)?.sortedBy { val dx = centerX - (it.boundingBox?.centerX() ?: 0) val dy = centerY - (it.boundingBox?.centerY() ?: 0) dx * dx + dy * dy } ?: emptyList() val barcode = list.firstNotNullOfOrNull { it.rawValue } if (barcode != null) { setResult(RESULT_OK, Intent().apply { putExtra("barcode", barcode) }) finish() } } )这里使用了MlKitAnalyzer来提供CameraX需要的图像分析器对象。如果你看ML Kit条码扫描部分的文档，你会发现Google并没有告诉你这种使用方法，而是手动通过传递参数来实现的。而MlKitAnalyzer可以省略这部分，自动从CameraX获取需要的信息，并且它能保证你获取到的数据和Preview的坐标是相对应的。与Google的条码扫描器不同，ML Kit会返回一组条码，但我的应用只需要一个条码。因此我这里采用的策略是选择与屏幕中心距离最近的一个。由于条码的坐标和Preview的坐标是相对应的，因此中心坐标就可以通过previewView的宽和高计算得出，进而实现选择。如果最终选择出来的条码不为空，那么获取它的字符串值传递给调用者就好了。解决了页面逻辑，下面简单说说渲染： AndroidView( factory = { previewView }, modifier = Modifier.fillMaxSize(), ) var torchStatus by remember { mutableStateOf(false) } var fillStatus by remember { mutableStateOf(true) } Column( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth() ) { if (torchStatus) { IconButton(onClick = { cameraController.enableTorch(false) torchStatus = false }) { Icon( imageVector = Icons.Default.FlashOn, contentDescription = "turn off torch" ) } } else { IconButton(onClick = { cameraController.enableTorch(true) torchStatus = true }) { Icon( imageVector = Icons.Default.FlashOff, contentDescription = "turn on torch" ) } } if (fillStatus) { IconButton(onClick = { previewView.scaleType = PreviewView.ScaleType.FIT_CENTER fillStatus = false }) { Icon( imageVector = Icons.Default.ZoomInMap, contentDescription = "switch to fill" ) } } else { IconButton(onClick = { previewView.scaleType = PreviewView.ScaleType.FILL_CENTER fillStatus = true }) { Icon( imageVector = Icons.Default.ZoomOutMap, contentDescription = "switch to fit" ) } } } }这里关于预览就不多说了，主要说说按钮。按钮部分，每个按钮都有两种状态。对于闪光灯来说，图标总是表示闪光灯当前的状态，并在点击后切换到相反状态。有一些APP则是表示闪光灯在按下之后的状态，并在点击后切换到这个状态。我觉得这个就看个人吧，我更喜欢展示当前状态一些。另一个是控制预览缩放。默认情况下是填充屏幕，而此时的图标是缩小，点击后就会变成适应屏幕，也就是通过缩放将画面呈现在屏幕中；而在适应屏幕时，图标变成放大，表示通过缩放让预览充满屏幕。调用方式因为我在安卓方面的经验不够丰富，了解的也不多。作为个人项目来说，我的主要目标是能用。所以我设计了一种比较简单的交互方式。因为要处理权限问题，所以我之前引入了一个VazanActivity，通过子类调用super.onCreate来帮助子类请求权限。因此对于registerForActivityResult的调用也可以放在这里。但问题在于，这个调用注册的回调是固定的，没办法在运行时修改。不过这也好办，搞一个队列就是了： private val scannerCallbackQueue = ConcurrentLinkedQueue<Pair<(String) -> Unit, () -> Unit>>() private val scannerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { val (onSuccess, onFailed) = scannerCallbackQueue.poll() ?: error("Missing callback") val barcode = it.data?.getStringExtra("barcode") if (it.resultCode == RESULT_OK && barcode != null) onSuccess(barcode) else onFailed() }这里我采用了简化的设计，回调一共就两种，一种是成功，一种是取消或者失败，总之前者表示有结果，后者表示没有结果。调用时我们先把回调放入队列，然后拉起Activity进行扫码。扫码的Activity退出之后就会执行到我们注册的回调。如果返回值为成功，并且有条码结果，则调用成功的回调；如果没有，则调用失败的回调。 protected fun scanBarcode(onSuccess: (String) -> Unit, onFailed: () -> Unit) = synchronized(scannerCallbackQueue) { scannerCallbackQueue.offer(onSuccess to onFailed) scannerLauncher.launch(intent(ScannerActivity::class)) }这里我对扫码操作的调用加了锁。严格来说不加锁也没问题，无论扫码的Activity怎么乱序启动，最终处理回调的时候保证先进先出就可以了。如果更严谨一点的话，可以使用Object的await和notifyAll来确保队列中等待处理的回调至多只有一个。很遗憾，我没有那么严谨，我觉得加个锁就差不多了。至于迁移，也很好办。由于不需要创建扫码Client的，因此可以直接删掉这部分代码，对于扫码的调用，可以直接换成scanBarcode。在回调方面，成功的回调几乎不变，而失败的回调作为取消处理，不考虑异常捕捉的回调。至此，我觉得这一系列应该真的完结了吧。-全文完-
【代码札记】从零开始的安卓应用开发 - P3
本文介绍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就好了，效果如下：至于实际上主菜单都要有哪些功能，我想了想，就五个：同步：将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的时候删除对应的缓存。同步有了配置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 { // ...... } } } }打印数据同步完成之后就可以考虑打印标签的部分了。打印部分分为两个页面：生成标签和打印。生成标签有三种方式：随机生成：保证生成的标签在数据库中没有出现过扫描：扫描正在使用或已经打印过的标签，一般用于重复打印手动输入：我想不到什么需要手动输入的场景，但是有总比没有好打印界面主要是和标签编码相关的，确定好类型和标签之后将标签传给打印界面。打印界面将生成打印标签的预览，以及选择打印机、纸张、重复次数等，并实际调用蓝牙联络打印机。在打印页面返回成功后还会更新数据库，将刚刚打印的标签插入数据库，这时status为PRINTED，version为0。备份前面对着数据库一顿操作，如果数据丢了就糟糕了。好在我们的数据库比较简单，这里我使用了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而扫码的部分也相对简单：调用GMS扫码服务根据扫描到的条码，查询数据库中是否存在该标签，并且该标签是否存在对应的Entity ID若该条码存在，则判断条码的类型，查询设置中对应的库ID和字段ID根据目的地发出请求请求成功后再次调用扫码服务，回到step1这部分是一个循环，用户可以在扫码阶段执行返回操作，进而取消扫码。这时扫码部分的Activity就会顺势结束，退出快速扫描的操作。总结至此，关于我的第一个安卓APP就已经全部讲解完了。这一片拖得有点久，因为一直没有想明白UI部分到底应该怎么写。关于布局的代码又臭又长，而且还没什么新意，但是有些东西用起来很自然，写起来不一定自然，可是说不自然吧，又没达到复杂的程度，这种东西拿出来写显得啰嗦，不拿出来写又觉得过于省略了，实在是很难定夺。最后我觉得还是不要事无巨细的说比较好，毕竟UI做出来是要用的，不是拿来吹的。在交互上有不少细节，你不提没人知道，你加上了别人会觉得很顺畅很舒服，但是你提起来又会显得很啰嗦。目前这个APP已经完全开源在了GitHub上。虽然对于开源软件我不提供任何质量和可用性的保障，但是这个APP毕竟是我自己用的APP，能用肯定是基本要求。-全文完-
【代码札记】从零开始的安卓应用开发 - 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用来移动东西。预计可能还需要一到两篇才能说完吧。
【代码札记】从零开始的安卓应用开发 - P1
本文来介绍APP的整体架构和数据源设计。前文简单描述了这个APP的思路来源和潜在的应用场景，本文首先介绍一下APP的技术和架构。技术和架构众所周知，我很讨厌在UI中使用XML来定义布局。我不止一次自己写了一个简单的声明式框架来帮助我渲染UI，这其中就包括我的软件课设和毕业设计。所以同样地，我也讨厌在安卓中使用XML文件来定义布局。好在Google整出来了一个Jetpack Compose，它基于Kotlin，允许开发者使用声明式的方式定义和渲染布局，并且整个生态支持的相对还不错，在我看在，大部分情况下要比原来的UI更加便于使用。关于数据库和其他框架部分，几乎都支持Kotlin协程，而不用依赖额外的什么RxJava这些。在我看来甚是得体和优雅。总体来说，APP的界面部分使用Compose来实现，数据库使用Room框架，HTTP请求则使用Retrofit2框架，外观使用Material 3，依赖注入部分使用dagger hilt。对于我的第一个安卓APP来说，这看起来还挺中规中矩的是吧。关于应用程序的架构，好像我并没有遵循一个特定的准则。我更倾向于凭直觉分割代码。例如数据源的部分分成来自Room数据库的和来自API的；基于这些数据源，针对应用的功能提供类似于Repository、用于提供交互的接口（即业务逻辑不考虑这些数据的原始形式）；然后是业务逻辑，通常不考虑UI部分，例如生成条码等；最后是UI部分。我也不知道我这种组织方式叫什么，反正写代码的是我，我自己开心就好。数据源设计接下来我们说说数据源的设计。数据源分两种：数据库和Memento API。Memento在正式开始之前，我们先说一说Memento那边需要怎么建立Library。我打算分为三个Library：位置、容器和物品。位置部分比较简单，唯一的要求就是具有一个能够区分不同位置的字段。这个字段在Quick Scan操作（指定目的地，通过扫码来向API发出请求，实现将被扫描的物品移动到目标位置）中会被显示给用户，让用户挑选一个位置作为目的地。容器则相对麻烦一些：容器可能在一个位置下（即位置与容器是1对N），也可能在其他容器里（容器与容器1对N），但这个容器不可能既在一个位置，又在另一个容器里，遗憾的是Memento数据库没办法提供这种检查，但索性我们可以通过编程来修改（设置字段的时候将另一个字段设置为null）。同时容器还要求一个条码字段。物品与容器类似，但这次条码字段不是必填的，因为考虑到有些物品太小了，没必要贴条码（真的不会有人给每一根数据线都贴上条码吧，不会吧不会吧不会吧）。除了上述必须的字段，剩余字段可以自由发挥。例如在物品这里可以添加一个勾选框，用来标识这个物品有没有内含电池，这样收纳的时候就可以针对有电池的物品特殊照顾。而这些字段都是和APP不相关的，不会干扰APP的运行。数据库首先是数据库设计。由于核心数据都存储在Memento那边，所以APP这块只要记录最基础的数据就好了。实体首先是负责记录配置的键值对，这个表比较通用，充当了一个KV存储：@Entity( tableName = "configs", primaryKeys = ["config_key"], indices = [Index("config_key", unique = true)] ) data class Config( @ColumnInfo(name = "config_key") val key: String, @ColumnInfo(name = "config_value") val value: String, )在使用时，键的部分通常以scope.namespace.something的形式来表示。例如有关APP中记录最后一次使用的打印机地址可以表示为app.thermal_printer.last_address，而用于访问memento API的密钥则表示为memento.api_key，类似地，记录Memento中存储位置的库的ID可以表示为memento.location.library_id，大概就是这种感觉。值的部分则使用String表示，由具体的设置自行决定。然后是记录标签的表。在打印的时候我们需要获知正在使用的标签（来自Memento API的数据），同时也要记录我们曾经打印过但还没有在Memento中使用的标签（由我们加入到数据库中）。另外在Quick Scan操作中，我们需要Entity ID来编辑被移动物品的位置，即通过扫描物品的标签，我们需要记录标签到Entity ID的映射。同时基于标签的编码形式，我们不需要区分这个标签是容器标签还是物品标签，因为容器标签总是以字母B开头，而物品标签总是以字母I开头。数据库设计如下：@Entity( tableName = "labels", primaryKeys = ["label_id"], indices = [ Index("label_id", unique = true), Index("entity_id", unique = true), Index("label_status", "version"), ] ) data class Label( @ColumnInfo(name = "label_id") val labelId: String, @ColumnInfo(name = "label_status") val status: Status, @ColumnInfo(name = "version") val version: Long, @ColumnInfo(name = "entity_id") val entityId: String?, )这里还有一个额外的version字段，这个是为了同步设计的。每次从Memento API拉取数据时，会把获取到的标签的version字段设置为一个时间戳，这样同步结束之后，version字段不一样的标签就是这次同步后被删除的标签。被删除则是因为这个标签曾经在Memento数据库中出现过，但现在消失了，这种场景对应了逻辑上的盒子被丢弃了（可能是被贴上了新的标签，也可能是因为这个盒子被弃用了）。如果是因为网络原因导致这个盒子存在但没有传给APP，那么下次同步的时候还会添加回来，无需担心。关于标签的状态（status），这是区分一个标签来自Memento的（已被使用），还是来来自于APP的（已打印）： enum class Status { IN_USE, PRINTED }DAO关于DAO（Data Access Object），在配置那块没什么好说的，增删改查四个函数：插入或更新按照key获取配置删除配置列出所有配置在标签部分，除了基础的增删改查四个函数之外，还有：按照状态删除所有标签：有时候用户可能希望删除所有已打印标签的记录，或者删除所有正在使用的标签以重新同步按照状态和版本删除标签：在同步完成后，针对所有已使用的标签（有新同步来的，还有需要被删掉的老数据），删除所有version不等于给定version的标签，即删除所有旧的数据Repository每个数据库表对应一个Repository，由于不需要什么复杂的变换，因此直接调用DAO就是了。这是配置的：interface ConfigRepository { suspend fun getConfigByKey(key: String): Config? suspend fun insertOrUpdateConfig(config: Config) suspend fun deleteConfig(config: Config) private suspend fun getConfigOrBlank(settingsKey: SettingsKey): String = getConfigByKey(settingsKey.key)?.value ?: "" /** * Get the item library id (box or item), and the parent location and box fields. * * Item library id might be blank if config is not set. * Fields id might be null if config is not set. * * @return Triple(itemLibId, itemParentLocationField, itemParentBoxField) * */ suspend fun resolveConfig(prefix: String): Triple<String, Int?, Int?> { // ...... } } class ConfigRepositoryRoomImpl @Inject constructor( private val dao: ConfigDao ) : ConfigRepository { override suspend fun getConfigByKey(key: String): Config? = dao.getConfigByKey(key) override suspend fun insertOrUpdateConfig(config: Config) = dao.insertOrUpdateConfig(config) override suspend fun deleteConfig(config: Config) = dao.deleteConfig(config) }配置部分我在后期额外添加了一个resolveConfig，这个是因为同样的解析配置的操作出现在了多个类中，基于面向对象的考量，这部分代码被抽象放到了ConfigRepository里。这个函数的做用就是给定一个标签，来查询这个标签应该使用的库ID（是容器库还是物品库），以及这个库中表示位置的字段。这是标签的：interface LabelRepository { suspend fun getLabelById(labelId: String): Label? suspend fun insertOrUpdateLabel(label: Label) suspend fun deleteLabel(label: Label) suspend fun deleteLabelByStatus(status: Label.Status) suspend fun deleteOldLabelsByStatus(status: Label.Status, latestVersion: Long) } class LabelRepositoryRoomImpl @Inject constructor( private val dao: LabelDao ) : LabelRepository { override suspend fun getLabelById(labelId: String): Label? = dao.getLabelById(labelId) override suspend fun insertOrUpdateLabel(label: Label) = dao.insertOrUpdateLabel(label) override suspend fun deleteLabel(label: Label) = dao.deleteLabel(label) override suspend fun deleteLabelByStatus(status: Label.Status) = dao.deleteLabelByStatus(status) override suspend fun deleteOldLabelsByStatus(status: Label.Status, latestVersion: Long) = dao.deleteOldLabelsByStatus(status, latestVersion) }Memento API数据库部分比较简单，接下来说说Memento API的部分。按照官方的说法，这个API还在Beta阶段，不过用起来感觉已经很稳定了。它的搜索接口有一些小bug，但我们用不上这个接口，无所谓。我们使用的接口有：列举库：列出当前API key有权访问的所有库列举库的详情：列出一个给定库的字段数据列举实体/记录：给定一个库，以分页的方式遍历所有记录更新记录：给定一个库和实体ID，修改指定的字段的值需要注意的是，目前API具有Rate limit，个人付费账户是每分钟30次，平均下来也就是2秒一次。我不想做特别复杂的控制逻辑，因此就单纯地在每次请求完成后等待3秒钟，保证不会触发限制。关于Retrofit怎么使用这里就不多说，这个库真的挺好用的，通过注解就把HTTP请求搞好了：interface MementoService { @GET("v1/libraries") suspend fun listLibraries(@Query("token") token: String): ListLibrariesDto @GET("v1/libraries/{libraryId}") suspend fun getLibraryInfo( @Path("libraryId") libraryId: String, @Query("token") token: String ): GetLibraryDto @GET("v1/libraries/{libraryId}/entries") suspend fun listEntriesByLibraryId( @Path("libraryId") libraryId: String, @Query("token") token: String, @Query("pageSize") pageSize: Int? = null, @Query("pageToken") pageToken: String? = null, @Query("fields") fields: String? = null, @Query("startRevision") startRevision: Int? = null, ): ListEntriesByLibraryIdDto @PATCH("v1/libraries/{libraryId}/entries/{entryId}") suspend fun updateEntryByLibraryIdAndEntryId( @Path("libraryId") libraryId: String, @Path("entryId") entryId: String, @Body updateEntryDto: UpdateEntryDto, @Query("token") token: String ): EntryDto }你看，这里我只定义了一个接口，用注解表示出对应的HTTP动作，对应的参数和返回值，之后Retrofit就可以自动帮我生成代码来发送HTTP请求，并解析返回值了。关于DTO，写起来也不难，这里我选用的moshi这个库来将Json转换成对象，从响应结果转换到对象也是Retrofit框架负责的一部分，我只需要定义对象就好了，例如：data class GetLibraryDto( @field:Json(name = "id") val id: String, @field:Json(name = "name") val name: String, @field:Json(name = "owner") val owner: String, @field:Json(name = "createdTime") val createdTime: String, @field:Json(name = "modifiedTime") val modifiedTime: String, @field:Json(name = "revision") val revision: Int, @field:Json(name = "size") val size: Int, @field:Json(name = "fields") val fields: List<LibraryField> ) { data class LibraryField( @field:Json(name = "id") val id: Int, @field:Json(name = "type") val type: String, @field:Json(name = "name") val name: String, @field:Json(name = "role") val role: String?, @field:Json(name = "order") val order: Int ) }Repository类似地，我们也需要定义一个Repository让业务逻辑无需关心底层实现。在此之前，我们需要处理一些数据类型的转换。例如上面举例的GetLibraryDto.LibraryField，它是作为一个响应体来设计的，因为LibraryField是请求库详情的响应体的一部分。但我们的业务逻辑中也需要获取库的字段列表，如果让业务逻辑拿着这个响应的一部分做事，总觉得好像不太好。因此我们需要单独设计一个业务上的对象：data class LibraryField( val id: Int, val type: String, val name: String, ) { companion object { fun fromDto(obj: GetLibraryDto.LibraryField): LibraryField = LibraryField( id = obj.id, type = obj.type, name = obj.name ) } }这个LibraryField就是业务逻辑中专门描述库字段的模型，虽然定义上和那个响应体差不多，但概念上不同。看起来有些啰嗦，但当程序变得复杂的时候，我想你肯定不希望你的底层API和业务逻辑紧紧绑定在一起。类似地，我们在列出所有库的时候，我们只希望将有用的信息呈现给业务逻辑：data class LibraryBrief( val id: String, val name: String, val owner: String ) { companion object { fun fromDto(obj: ListLibrariesDto.ListLibrariesEntry): LibraryBrief = LibraryBrief( id = obj.id, name = obj.name, owner = obj.owner ) } }也就是表的ID、表的名称和表的所有者。至于什么修改时间、revision这些，我们目前还不关心。Repository的设计也与数据库不同，数据库的DAO给出来的数据就可以直接用，但这个API给出的来的数据不能直接交给业务逻辑，例如那个分页遍历。理想情况下，我们希望使用诸如Sequence或者Flow这样的数据结构来表示一个动态生成的数据流（相比之下，List和数组表示所有数据都在内存中等待使用）。换句话说，业务逻辑并不希望了解你的API是怎么分页的，无论是通过page和pageSize，还是通过nextPageToken，业务逻辑期待一个更加规范的包装。Repository的接口是这样的：interface MementoRepository { suspend fun listLibraries(): List<LibraryBrief> suspend fun getLibraryFields(libraryId: String): List<LibraryField> suspend fun listEntriesByLibraryId( libraryId: String, pageSize: Int? = null, fields: String? = null, startRevision: Int? = null, ): Flow<List<EntryDto>> suspend fun updateEntryByLibraryIdAndEntryId( libraryId: String, entryId: String, updateEntryDto: UpdateEntryDto, ): EntryDto }可以看到在listEntriesByLibraryId那块，它的返回值是Flow<List<EntryDto>>。那么，什么是Flow呢？在此之前我们先介绍一下Sequence。序列是Kotlin中提供的一种生成式流，有点类似于Iterator，但使用了协程。关于协程的定义，我也不是什么严谨的学术工作者，所以这里就不献丑了。下面我来举一个例子，希望读者可以从中体会到Sequence和Iterator的不同。假如我们要按需产生一个从Int最小值到Int最大值的序列，首先来说我们不会把它存在内存里，因为他太多了。但是我们需要在逻辑上使用这样一个序列，怎么办呢？使用Iterator可以实现：object : Iterator<Int> { val counter = AtomicInteger(Int.MIN_VALUE) override fun hasNext(): Boolean = counter.get() < Int.MAX_VALUE override fun next(): Int = counter.getAndIncrement() }我们的迭代器内部有一个计数器。我们通过对比当前计数器的值来判断是否还有下一个元素。我们需要在别人调用next方法时产生下一个元素。Sequence使用协程来产生元素：sequence { for (i in Int.MIN_VALUE..Int.MAX_VALUE) { yield(i) } }这里的yield是sequence {}函数提供的，它是一个suspend装饰的函数，换句话说它是可以被挂起的。这里我们并不需要考虑别人是否调用了next，我们只需要不停的产出下一个元素。在代码执行时，我们在产出第一个元素的时候（调用yield那块），我们的代码执行就会被暂停，直到我们产出的这个元素被使用了（类似于别人调用了next）。使用之后我们的代码继续执行，产出下一个元素（下一次调用yield），然后又被暂停了，等待这个圆度被消耗（别人调用next）。在整个过程中，我们不需要考虑别人什么时候调用next，我们只需要考虑怎么产出下一个元素。在实现上，Sequence和Iterator并没有太大的差距。Iterator的hasNext实际上就是执行sequence里面的方法体，直到方法体调用了一个yield或退出。如果调用了yield，那么就还有下一个；如果退出了，说明方法体执行完毕，没有下一个了。尽管二者在底层实现上类似，但在概念上完全不同。编写Iterator的时候我们需要考虑别人调用的时机，为了避免数据竞争，我使用了AtomicInteger；但编写Sequence的时候就完全不需要考虑，我只想着如何产生这么多元素就是了。而产生一个连续的数列，最简单的方法就是用for循环。你看，两种完全不同的思路，完全不同的实现方法，但实现了完全一样的功能。这就是协程的力量。但是脑子尖的读者可能就要问了：Sequence这么好，那flow又是个什么东西？问得好，下次不许问了。Sequence虽好，但它毕竟是发生在一个线程里的事情。别人调用我们的Sequence时，是一个线程在执行不同的代码。我们管调用我们的角色叫消费者好了。在一个线程上，消费者调用了我们的sequence，于是线程开始执行我们的代码，我们的代码调用了yield，于是线程转而继续调用消费者的代码；消费者再次调用我们的sequence请求下一个元素，这时线程暂停执行消费者的代码，转而执行我们的代码来产生下一个元素。这个过程虽然精妙，但它只发生在一个线程上。幸运的是，产生数字并不需要花费太多时间。但请求API就不一样了。例如我们需要在UI上显示一个列表，这个列表的值源于一个API。如果使用Sequence的话，当UI开始渲染，不断请求元素的时候，负责更新UI的线程就会开始执行我们的代码，请求API。由于涉及到网络IO，在没有得到相应之前，更新UI的线程就会被阻塞，直到API响应，我们的代码将其解析成UI需要的数据，然后执行权回到UI部分。你猜猜看更新UI的线程被阻塞会发生什么事情？更新UI的线程被阻塞，不再响应操作系统的请求，对于用户来说就是APP假死、未响应。如果是了解底细的用户可能会耐心等待，但是不明所以的用户遇到假死的APP，第一反应就是结束应用，这样一来，你的APP看起来就像是一点开下拉列表就假死。很明显，这不是好的用户体验。那有没有办法在Sequence中使用多线程呢？很遗憾，不能。但幸运的是，我们不止有Sequence，我们还有Flow。Flow是一个冷数据流，我更愿意叫他可重复数据流。和Sequence一样，我们可以随时从头生成这些数据，但是和Sequence不同的是，我们可以使用多线程（Kotlin协程）：flow { for (i in Int.MIN_VALUE..Int.MAX_VALUE) { emit(i) delay(2000) } }在flow中，我们可以使用其他suspend装饰的函数了。也就是说，我们可以不只局限于生产者和消费者的代码了。从逻辑上说，别人调用我们的flow，我们可以通过emit来产生下一个元素，同时还可以调用delay来将代码的执行权转移到别的地方，比如在IO线程上执行HTTP请求，或者在其他线程上执行数据库操作。当然，使用flow的另一个好处就是取消。这里我们没有用到，但是取消是一个非常好的特性。以HTTP请求来说，我们不可能无限长时间的等待响应，所以我们应该设定一个超时时间：无论我们有没有获取到全部数据，超过了这个时间，就不再发送HTTP请求了。通过配合withTimeoutOrNull，使用Flow可以轻易的做到这一点。关于Kotlin协程，我这里只是片面的说了些皮毛。使用Flow还有其他很多好处，例如buffer允许生产者不受限制地产生数据，可以被多线程地处理等。如果你想深入了解Kotlin协程，这里我推荐阅读霍丙乾编写的《深入理解Kotlin协程》。P.S.：霍老师记得给我打钱好像说的有点多了，说回我们的MementoRepository，它的实现如下：class MementoRepositoryRetrofitImpl @Inject constructor( private val service: MementoService, private val config: ConfigRepository ) : MementoRepository { private val tag = "MementoRepositoryRetrofitImpl" private suspend fun getToken(): String = config.getConfigByKey(SettingsKey.MEMENTO_API_KEY.key)?.value ?: "" override suspend fun listLibraries(): List<LibraryBrief> = service.listLibraries(getToken()).libraries.map { LibraryBrief.fromDto(it) } override suspend fun getLibraryFields(libraryId: String): List<LibraryField> = service.getLibraryInfo(libraryId, getToken()).fields.map { LibraryField.fromDto(it) } override suspend fun listEntriesByLibraryId( libraryId: String, pageSize: Int?, fields: String?, startRevision: Int?, ): Flow<List<EntryDto>> { val token = getToken() return flow { var pageToken: String? = null do { val r = try { service.listEntriesByLibraryId( libraryId = libraryId, token = token, pageSize = pageSize, pageToken = pageToken, fields = fields, startRevision = startRevision ) } catch (e: IOException) { Log.e(tag, "Error when fetching entries: $libraryId, $pageToken", e) delay(5000) continue } catch (e: HttpException) { Log.e(tag, "Error when fetching entries: $libraryId, $pageToken", e) delay(5000) continue } val t = System.currentTimeMillis() pageToken = r.nextPageToken emit(r.entries) val dt = System.currentTimeMillis() - t // the api has rate limit of 30 request per minute // thus wait 2s for each request if (dt < 3000) { delay(3000 - dt) } } while (pageToken != null) } } override suspend fun updateEntryByLibraryIdAndEntryId( libraryId: String, entryId: String, updateEntryDto: UpdateEntryDto ): EntryDto = service.updateEntryByLibraryIdAndEntryId( libraryId = libraryId, entryId = entryId, updateEntryDto = updateEntryDto, token = getToken() ) }其他函数基本上都是直接调用了HTTP请求，唯独listEntriesByLibraryId使用了flow。我这里并没有使用太花哨的用法，就是简单的记录nextPageToken，然后不断请求下一页。关于错误处理也比较盲目，我简单的假设错误是因为遇到了rate limit，所以出错之后先等几秒钟，然后直接重试。如果获取到了数据，考虑到emit会阻塞线程，所以请求成功那一刻先记录时间，然后从emit处回复执行后再看一次时间，保证继续下一次请求之前等够3秒钟。总结虽然还有很多想介绍的内容，但是篇幅有限，只能将余下的内容放在另一篇文章中了。本次介绍了APP使用的技术和整体架构，随后又说了说数据源的设计。在说明API部分的时候额外多说了一些Kotlin协程的东西。这个在Java里是没有语言级的支持的，需要开发者使用诸如RxJava这些库来做到。而Kotlin中原生提供了协程的支持（suspend关键字），因此在IDE上也有良好的支持（当你看到左边代码行数那块出现一个箭头，你就知道这是一个挂起函数了）。我很喜欢这种简介且自成一体的设计，这可能也是最近几年我一直使用Kotlin的原因。总之，这一次先写到这里。下一篇文章我想简单说一说安卓上的依赖注入，然后说说业务上的设计。-全文完-
【代码札记】从零开始的安卓应用开发 - P0
虽然我以前也尝试过开发安卓APP，但最后都没能成功。好在这一次成功了，姑且就算它是第一次吧。我想写一个个人物品管理的APP。事情的起因源自于我时常找不到曾经买过的东西——这并非我丢三落四，而是我每年都要搬迁几次，每次搬迁都要把自己的东西打包到箱子里。然而安定下来之后，我不会一下子都把他们拿出来，如果没有用到的，那就留在箱子里，这样一来下次搬迁就不用动了。但是几次搬迁累积下来，基本上我也不知道什么东西在哪个箱子里了，尤其是之前没用到的。所以每次找东西都得挨个箱子翻找，搞得一团乱，有时候找到一般就很急躁地不想再找了。有一天我接快递的时候看到快递盒子上面贴的面单，这无意间提醒我了：我可以在箱子上贴一个二维码，然后扫一下不就知道里面是什么了。所以我在春节期间糊了一个APP，也就是本文所介绍的APP的第一个版本。这个版本使用了我之前折腾过的喵喵机P2，通过蓝牙调用打印机在热敏标签纸上打印一个二维码，这个二维码是一个UUID，每个盒子对应这么一个UUID，然后APP提供了简单的日志功能，扫描一个UUID就可以针对这个UUID按时间顺序添加备注。这个备注相对比较自由，你可以写放进去了什么东西，里面有什么，拿出来了什么，把箱子搬到了什么地方之类的。找东西的时候直接全文搜索，找到与关键词相关的备注，继而找到与备注相关的箱子。箱子上的标签除了二维码，还有文字版的UUID方便寻找。总第来说，第一版APP构造十分简单，易用性不高，但足够使用。第一版APP是我一边看文档一边凭感觉写的，使用了Jetpack Compose，没有XML的日子是真的爽啊！第一版的APP在代码上可谓是非常简陋了：只有一个数据库实体，一些抽象出来的类，剩下的页面逻辑就全都放在Activity里面了。那个时候的我也不知道什么MVC、MVVM这些东西，反正就硬写，能用就行。但话是这么说，虽然它在功能上满足了一个最小全功能集，但是这就好比，Java能做的事情汇编也能做，但没有人会用汇编来写复杂的业务。这个第一版也是一样。假如说你把一个物品从A箱子移动到B箱子，你就得先扫A箱子，记上我把东西拿出来了，或者删掉这个东西的备注，然后再扫B箱子，记上我把这个东西放进去了。如果期间疏忽大意了，很可能这个东西就在系统里找不到了，或者在系统里同时出现在多个箱子里，归根到底，还是没有彻底解决问题。我对于这个APP并不满意。首先它不够实用，其次我好像也没学到什么，就是花费了时间和精力编写了一个赛博垃圾。于是我开始寻找现有的解决方案。大部分我见到的解决方案都是针对商用场景的，比如IT资产管理或者供应链、库存管理等。对于我的需求太说，这就像是开宝马去菜市场买菜，实在是大可不必。后来我找到了两个相对合适的APP。第一个是HouseBook，看起来这个APP是由一个独立开发者编写的。这个APP给我带来了不少启发，例如多个实体类型：房屋、位置、容器、物品，其中房屋是最顶级的单位，一个房屋可以有多个位置，一个位置下面可以有容器或物品，而一个容器里也可以有子容器。同时它也提供了一种比较方面的交互界面：容器和物品可以与二维码绑定，容器可以有名字，物品可以有固定字段，例如品牌、型号、序列号、保质期等。在搜索方面也比较方便：可以使用文字搜索相关的容器或物品，也可以直接扫描二维码搜索对应的实体。我虽然很喜欢它的灵活性，但在物品字段方面我认为有些死板了：你只能使用应用中定死的，而不能自己新增字段。同时这个APP是付费的，需要一次性支付30USD来解锁全部功能，同时使用云同步的话，随着物品数量的增多，你可能还需要额外花钱购买云空间。不过这些都是一次性消费，没有持续订阅。第二个APP严格来说不算是物品管理，而是一个通用的个人数据库，叫做Memento Database，背后是一个商业公司在支持和维护（至少看起来像是，也许这个公司就只有开发者一个人也说不准）。同时这个APP支持安卓、iOS和桌面版，其中桌面版是Java写的，因此支持全平台。不过要跨平台使用，就需要订阅他们的高级计划。如果只是在一台设备上使用的话，那么不订阅也是可以享受到大部分功能的。这个App以库（Library）来组织数据，以数据库的语言来说，这个库其实就是一个表。你可以在表中定义字段，但是不需要像关系型数据库那么严格——你的表甚至可以没有唯一字段（因为每一个记录有一个内部的entity id）。同时在字段类型的选择上也非常丰富：字符串、整数、小数这些就不说了，什么布尔啊货币啊时间啊，这些都比较常见。他们还提供了条码字段，可以直接扫描条码来读取其中的信息，同时还提供了列表、树、标签、外键、自定义计算（基于其他字段计算出结果），如果这些还不能满足你的话，他们还支持JavaScript脚本。可以说是非常强大了。后来经过一番探索，他们还提供了一个云API用来操作开启云同步的库和数据，这一下子可玩性就高了啊。基于这种灵活度，我决定使用Memento Database，配合我自己开发的APP来进行操作。虽然这两个APP在输入上都提供了非常好的交互方式，但输出上面还是差了一点。虽然他们都支持扫描条码，但是条码得先打印才能扫描啊。与此同时，我受够了喵喵机的封闭，开始寻找商业化的便携打印机。这次我购买了佳博的M322便携打印机，它的打印质量非常一般（没办法打印精细图片，我尝试了调整曲线和交错打印——是的，通过控制打印机把纸倒回来，我可以分多次打印一幅画，但倒纸的精确度有待商榷——都没有得到满意的结果），只能用来打标签（毕竟人家是标签打印机，没说是热敏图片打印机），但和喵喵机不同，它具有多种传感器，也就是说我们可以使用正经的标签纸，而不是连续纸了。不过归根结底，这个打印机最最重要的一点是厂家提供技术手册：你可以使用SDK（或者和我一样自己写SDK）光明正大的和打印机交互，而不必考虑什么逆向工程之类的东西。总之，在第二个版本中，我的思路是将数据存到Memento Database中，然后利用我的APP与之交互。大体的使用场景就是：先在我的APP中调用打印机打印条码标签，然后在Memento中添加这个标签，与盒子或物品做绑定，记录信息。需要查询的时候就直接在Memento中搜索或扫码。如果需要移动箱子或物品的话，可以在我的APP中选定好目的地位置或箱子，然后连续扫码，将扫到的物品或箱子通过API移动到目的地。这一套组合下载，堪比商用解决方案，岂不美哉？当然了，这个系列的文章是站在事后诸葛亮的角度上写的。其实一开始我也不知道第二版应该怎么做。就是觉得第一版不够好，连我自己都不想用，那肯定是要改。第二版就参照着HouseBook和Memento Database，一边用一边找思路，最终摸索成现在的模样。此外，由于篇幅有限，我想把这个作为一个系列来写，这一次是序言，下一次会介绍整体架构和使用的技术，如果还有篇幅的话就介绍一下数据库设计，再之后是标签设计和蓝牙打印，然后是联动Memento的API，最后说一说UI设计。序言部分暂且先告一段落，我们下一篇文章见。-全文完-